From b9a123c71b362b8acb04a674ceb6ddab436b1d5d Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Sat, 12 Apr 2025 07:35:34 -0400 Subject: [PATCH 01/13] rename MeasureSequenceIterator to LegacyMeasureSequenceIterator --- ...or.ts => legacymeasuresequenceiterator.ts} | 2 +- src/playback/timeline.ts | 4 +-- .../playback/measuresequenceiterator.test.ts | 26 +++++++++---------- 3 files changed, 16 insertions(+), 16 deletions(-) rename src/playback/{measuresequenceiterator.ts => legacymeasuresequenceiterator.ts} (98%) diff --git a/src/playback/measuresequenceiterator.ts b/src/playback/legacymeasuresequenceiterator.ts similarity index 98% rename from src/playback/measuresequenceiterator.ts rename to src/playback/legacymeasuresequenceiterator.ts index 984aba679..6c0641b09 100644 --- a/src/playback/measuresequenceiterator.ts +++ b/src/playback/legacymeasuresequenceiterator.ts @@ -10,7 +10,7 @@ type Jump = { type: 'repeatstart' } | { type: 'repeatend'; times: number } | { t /** * A class that iterates over measures in playback order (accounting for repeats and jumps). */ -export class MeasureSequenceIterator implements Iterable { +export class LegacyMeasureSequenceIterator implements Iterable { private measures: T[]; constructor(measures: T[]) { diff --git a/src/playback/timeline.ts b/src/playback/timeline.ts index 6b131c644..0408fe2a7 100644 --- a/src/playback/timeline.ts +++ b/src/playback/timeline.ts @@ -2,7 +2,7 @@ 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 { LegacyMeasureSequenceIterator } from './legacymeasuresequenceiterator'; import * as util from '@/util'; export class Timeline { @@ -67,7 +67,7 @@ class TimelineFactory { const measures = this.score.getMeasures(); const measureIndexes = Array.from( - new MeasureSequenceIterator(measures.map((measure, index) => ({ index, jumps: measure.getJumps() }))) + new LegacyMeasureSequenceIterator(measures.map((measure, index) => ({ index, jumps: measure.getJumps() }))) ); const result = new Array<{ measure: elements.Measure; willJump: boolean }>(); diff --git a/tests/unit/playback/measuresequenceiterator.test.ts b/tests/unit/playback/measuresequenceiterator.test.ts index 4e4eb5ca3..8e2c7282c 100644 --- a/tests/unit/playback/measuresequenceiterator.test.ts +++ b/tests/unit/playback/measuresequenceiterator.test.ts @@ -1,14 +1,14 @@ -import { MeasureSequenceIterator } from '@/playback/measuresequenceiterator'; +import { LegacyMeasureSequenceIterator } from '@/playback/legacymeasuresequenceiterator'; -describe(MeasureSequenceIterator, () => { +describe(LegacyMeasureSequenceIterator, () => { it('is empty when there are no measures', () => { - const iterator = new MeasureSequenceIterator([]); + const iterator = new LegacyMeasureSequenceIterator([]); expect(iterator).toBeEmpty(); }); it('is the same as the input when there are no repeats', () => { - const iterator = new MeasureSequenceIterator([ + const iterator = new LegacyMeasureSequenceIterator([ { index: 0, jumps: [] }, { index: 1, jumps: [] }, { index: 2, jumps: [] }, @@ -18,7 +18,7 @@ describe(MeasureSequenceIterator, () => { }); it('repeats a single measure', () => { - const iterator = new MeasureSequenceIterator([ + const iterator = new LegacyMeasureSequenceIterator([ { index: 0, jumps: [{ type: 'repeatstart' }, { type: 'repeatend', times: 1 }] }, ]); @@ -26,7 +26,7 @@ describe(MeasureSequenceIterator, () => { }); it('repeats a single measure multiple times', () => { - const iterator = new MeasureSequenceIterator([ + const iterator = new LegacyMeasureSequenceIterator([ { index: 0, jumps: [{ type: 'repeatstart' }, { type: 'repeatend', times: 3 }] }, ]); @@ -34,7 +34,7 @@ describe(MeasureSequenceIterator, () => { }); it('repeats a single measure when the start is not at the beginning', () => { - const iterator = new MeasureSequenceIterator([ + const iterator = new LegacyMeasureSequenceIterator([ { index: 0, jumps: [] }, { index: 1, jumps: [{ type: 'repeatstart' }] }, { index: 2, jumps: [{ type: 'repeatend', times: 1 }] }, @@ -44,7 +44,7 @@ describe(MeasureSequenceIterator, () => { }); it('repeats multiple measures', () => { - const iterator = new MeasureSequenceIterator([ + const iterator = new LegacyMeasureSequenceIterator([ { index: 0, jumps: [{ type: 'repeatstart' }] }, { index: 1, jumps: [{ type: 'repeatend', times: 1 }] }, ]); @@ -53,7 +53,7 @@ describe(MeasureSequenceIterator, () => { }); it('repeats multiple measures multiple times', () => { - const iterator = new MeasureSequenceIterator([ + const iterator = new LegacyMeasureSequenceIterator([ { index: 0, jumps: [{ type: 'repeatstart' }] }, { index: 1, jumps: [{ type: 'repeatend', times: 2 }] }, ]); @@ -62,7 +62,7 @@ describe(MeasureSequenceIterator, () => { }); it('repeats endings', () => { - const iterator = new MeasureSequenceIterator([ + const iterator = new LegacyMeasureSequenceIterator([ { index: 0, jumps: [{ type: 'repeatstart' }] }, { index: 1, jumps: [{ type: 'repeatending', times: 1 }] }, { index: 2, jumps: [] }, @@ -72,7 +72,7 @@ describe(MeasureSequenceIterator, () => { }); it('repeats multiple endings', () => { - const iterator = new MeasureSequenceIterator([ + const iterator = new LegacyMeasureSequenceIterator([ { index: 0, jumps: [{ type: 'repeatstart' }] }, { index: 1, jumps: [{ type: 'repeatending', times: 2 }] }, { index: 2, jumps: [] }, @@ -82,7 +82,7 @@ describe(MeasureSequenceIterator, () => { }); it('handles implicit start repeats', () => { - const iterator = new MeasureSequenceIterator([ + const iterator = new LegacyMeasureSequenceIterator([ { index: 0, jumps: [] }, { index: 1, jumps: [{ type: 'repeatend', times: 1 }] }, ]); @@ -91,7 +91,7 @@ describe(MeasureSequenceIterator, () => { }); it('handles multiple implicit start repeats', () => { - const iterator = new MeasureSequenceIterator([ + const iterator = new LegacyMeasureSequenceIterator([ { index: 0, jumps: [] }, { index: 1, jumps: [{ type: 'repeatend', times: 1 }] }, { index: 2, jumps: [{ type: 'repeatend', times: 1 }] }, From 28d4f9855a5585fe182235f3184e805583c229af Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Mon, 14 Apr 2025 14:50:00 -0700 Subject: [PATCH 02/13] create ElementDescriber class --- src/playback/elementdescriber.ts | 60 ++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 src/playback/elementdescriber.ts diff --git a/src/playback/elementdescriber.ts b/src/playback/elementdescriber.ts new file mode 100644 index 000000000..4b67c47e8 --- /dev/null +++ b/src/playback/elementdescriber.ts @@ -0,0 +1,60 @@ +import { PlaybackElement } from './types'; +import * as elements from '@/elements'; +import * as util from '@/util'; + +/** + * Describes playback elements in a human readable format. + */ +export class ElementDescriber { + private constructor(private elements: Map) {} + + static create(score: elements.Score, partIndex: number): ElementDescriber { + 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 ElementDescriber(elements); + } + + describe(element: PlaybackElement): string { + switch (element.name) { + case 'note': + return this.describeNote(element); + case 'rest': + return this.describeRest(element); + case 'fragment': + return this.describeFragment(element); + case 'measure': + return this.describeMeasure(element); + } + } + + private describeMeasure(element: elements.Measure): string { + return `measure(${element.getAbsoluteMeasureIndex()})`; + } + + private describeFragment(element: elements.Fragment): string { + return '[fragment]'; + } + + private describeRest(element: elements.Rest): string { + util.assert(this.elements.has(element), 'Expected element to be indexed'); + const index = this.elements.get(element)!; + return `element(${index})`; + } + + private describeNote(element: elements.Note): string { + util.assert(this.elements.has(element), 'Expected element to be indexed'); + const index = this.elements.get(element)!; + return `element(${index})`; + } +} From 486a8a832acdd0a6fc262ff7be953e1984ef1747 Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Mon, 14 Apr 2025 14:51:17 -0700 Subject: [PATCH 03/13] remove unused fragment --- src/playback/elementdescriber.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/playback/elementdescriber.ts b/src/playback/elementdescriber.ts index 4b67c47e8..db79f16dd 100644 --- a/src/playback/elementdescriber.ts +++ b/src/playback/elementdescriber.ts @@ -32,7 +32,7 @@ export class ElementDescriber { case 'rest': return this.describeRest(element); case 'fragment': - return this.describeFragment(element); + return this.describeFragment(); case 'measure': return this.describeMeasure(element); } @@ -42,7 +42,7 @@ export class ElementDescriber { return `measure(${element.getAbsoluteMeasureIndex()})`; } - private describeFragment(element: elements.Fragment): string { + private describeFragment(): string { return '[fragment]'; } From 4694f5cc833b5c1fa35c0320c29d9171c4112550 Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Mon, 14 Apr 2025 15:09:01 -0700 Subject: [PATCH 04/13] wire in ElementDescriber to CursorFrameDescriber --- src/elements/score.ts | 6 +- src/playback/defaultcursorframe.ts | 36 +++----- src/playback/elementdescriber.ts | 32 +++++--- src/playback/index.ts | 1 + .../unit/playback/defaultcursorframe.test.ts | 82 ++++++++++++++++--- 5 files changed, 109 insertions(+), 48 deletions(-) diff --git a/src/elements/score.ts b/src/elements/score.ts index a2890bc3c..f7032da61 100644 --- a/src/elements/score.ts +++ b/src/elements/score.ts @@ -78,7 +78,8 @@ export class Score { const timeline = this.getTimelines().find((timeline) => timeline.getPartIndex() === partIndex); util.assertDefined(timeline); - const frames = playback.DefaultCursorFrame.create(this.log, this, timeline, span); + const elementDescriber = playback.ElementDescriber.create(this, { partIndex }); + const frames = playback.DefaultCursorFrame.create(this.log, this, timeline, span, elementDescriber); const path = new playback.CursorPath(partIndex, frames); const cursor = playback.Cursor.create(path, this.getScrollContainer()); @@ -369,7 +370,8 @@ export class Score { const timeline = timelines.find((timeline) => timeline.getPartIndex() === partIndex); util.assertDefined(timeline); const span = { fromPartIndex: partIndex, toPartIndex: partIndex }; - const frames = playback.DefaultCursorFrame.create(this.log, this, timeline, span); + const elementDescriber = playback.ElementDescriber.create(this, { partIndex }); + const frames = playback.DefaultCursorFrame.create(this.log, this, timeline, span, elementDescriber); const path = new playback.CursorPath(partIndex, frames); paths.push(path); } diff --git a/src/playback/defaultcursorframe.ts b/src/playback/defaultcursorframe.ts index 3bd78b11b..fde23cd07 100644 --- a/src/playback/defaultcursorframe.ts +++ b/src/playback/defaultcursorframe.ts @@ -4,6 +4,7 @@ import { Logger } from '@/debug'; import { DurationRange } from './durationrange'; import { CursorFrame, CursorVerticalSpan, PlaybackElement, TimelineMoment } from './types'; import { Timeline } from './timeline'; +import { ElementDescriber } from './elementdescriber'; type TRangeSource = { moment: TimelineMoment; @@ -32,7 +33,8 @@ export class DefaultCursorFrame implements CursorFrame { log: Logger, score: elements.Score, timeline: Timeline, - span: CursorVerticalSpan + span: CursorVerticalSpan, + elementDescriber: ElementDescriber ): DefaultCursorFrame[] { const partCount = score.getPartCount(); if (partCount === 0) { @@ -48,7 +50,7 @@ export class DefaultCursorFrame implements CursorFrame { throw new Error(`Invalid toPartIndex: ${span.toPartIndex}, must be in [0,${partCount - 1}]`); } - const factory = new CursorFrameFactory(log, score, timeline, span); + const factory = new CursorFrameFactory(log, score, timeline, span, elementDescriber); return factory.create(); } @@ -92,9 +94,10 @@ class CursorFrameFactory { private log: Logger, private score: elements.Score, private timeline: Timeline, - private span: CursorVerticalSpan + private span: CursorVerticalSpan, + elementDescriber: ElementDescriber ) { - this.describer = CursorFrameDescriber.create(score, timeline.getPartIndex()); + this.describer = new CursorFrameDescriber(elementDescriber); } create(): DefaultCursorFrame[] { @@ -289,22 +292,7 @@ class CursorFrameFactory { } 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); - } + constructor(private elementDescriber: ElementDescriber) {} describeTRange(tRangeSources: [TRangeSource, TRangeSource]): string { return `[${tRangeSources[0].moment.time.ms}ms - ${tRangeSources[1].moment.time.ms}ms]`; @@ -321,16 +309,16 @@ class CursorFrameDescriber { private describeXRangeSource(source: XRangeSource): string { switch (source.type) { case 'system': - return `${source.bound}(system(${source.system.getIndex()}))`; + return `${source.bound}(${this.elementDescriber.describe(source.system)})`; case 'measure': - return `${source.bound}(measure(${source.measure.getAbsoluteMeasureIndex()}))`; + return `${source.bound}(${this.elementDescriber.describe(source.measure)})`; case 'element': - return `${source.bound}(element(${this.elements.get(source.element)}))`; + return `${source.bound}(${this.elementDescriber.describe(source.element)})`; } } private describeYRangeSource(source: YRangeSource): string { - return `${source.bound}(system(${source.part.getSystemIndex()}), part(${source.part.getIndex()}))`; + return `${source.bound}(system(${source.part.getSystemIndex()}), ${this.elementDescriber.describe(source.part)})`; } } diff --git a/src/playback/elementdescriber.ts b/src/playback/elementdescriber.ts index db79f16dd..27f3a5c2b 100644 --- a/src/playback/elementdescriber.ts +++ b/src/playback/elementdescriber.ts @@ -8,7 +8,7 @@ import * as util from '@/util'; export class ElementDescriber { private constructor(private elements: Map) {} - static create(score: elements.Score, partIndex: number): ElementDescriber { + static create(score: elements.Score, { partIndex }: { partIndex: number }): ElementDescriber { const elements = new Map(); score @@ -25,8 +25,12 @@ export class ElementDescriber { return new ElementDescriber(elements); } - describe(element: PlaybackElement): string { + describe(element: PlaybackElement | elements.System | elements.Part): string { switch (element.name) { + case 'part': + return this.describePart(element); + case 'system': + return this.describeSystem(element); case 'note': return this.describeNote(element); case 'rest': @@ -38,23 +42,31 @@ export class ElementDescriber { } } - private describeMeasure(element: elements.Measure): string { - return `measure(${element.getAbsoluteMeasureIndex()})`; + private describePart(part: elements.Part): string { + return `part(${part.getIndex()})`; + } + + private describeSystem(system: elements.System): string { + return `system(${system.getIndex()})`; + } + + private describeMeasure(measure: elements.Measure): string { + return `measure(${measure.getAbsoluteMeasureIndex()})`; } private describeFragment(): string { return '[fragment]'; } - private describeRest(element: elements.Rest): string { - util.assert(this.elements.has(element), 'Expected element to be indexed'); - const index = this.elements.get(element)!; + private describeRest(rest: elements.Rest): string { + util.assert(this.elements.has(rest), 'Expected element to be indexed'); + const index = this.elements.get(rest)!; return `element(${index})`; } - private describeNote(element: elements.Note): string { - util.assert(this.elements.has(element), 'Expected element to be indexed'); - const index = this.elements.get(element)!; + private describeNote(note: elements.Note): string { + util.assert(this.elements.has(note), 'Expected element to be indexed'); + const index = this.elements.get(note)!; return `element(${index})`; } } diff --git a/src/playback/index.ts b/src/playback/index.ts index 021937b97..16d71acf6 100644 --- a/src/playback/index.ts +++ b/src/playback/index.ts @@ -8,3 +8,4 @@ export * from './types'; export * from './defaultcursorframe'; export * from './lazycursorstatehintprovider'; export * from './emptycursorframe'; +export * from './elementdescriber'; diff --git a/tests/unit/playback/defaultcursorframe.test.ts b/tests/unit/playback/defaultcursorframe.test.ts index 829f46def..d5a144bb7 100644 --- a/tests/unit/playback/defaultcursorframe.test.ts +++ b/tests/unit/playback/defaultcursorframe.test.ts @@ -1,6 +1,6 @@ import * as vexml from '@/index'; import * as path from 'path'; -import { DefaultCursorFrame, Timeline } from '@/playback'; +import { DefaultCursorFrame, ElementDescriber, Timeline } from '@/playback'; import { NoopLogger, MemoryLogger } from '@/debug'; import fs from 'fs'; @@ -15,8 +15,15 @@ describe(DefaultCursorFrame, () => { it('creates for: single measure, single stave, different notes', () => { const [score, timelines] = render('playback_simple.musicxml'); + const elementDescriber = ElementDescriber.create(score, { partIndex: 0 }); - const frames = DefaultCursorFrame.create(log, score, timelines[0], { fromPartIndex: 0, toPartIndex: 0 }); + const frames = DefaultCursorFrame.create( + log, + score, + timelines[0], + { fromPartIndex: 0, toPartIndex: 0 }, + elementDescriber + ); expect(log.getLogs()).toBeEmpty(); expect(timelines).toHaveLength(1); @@ -46,8 +53,15 @@ describe(DefaultCursorFrame, () => { it('creates for: single measure, single stave, same notes', () => { const [score, timelines] = render('playback_same_note.musicxml'); + const elementDescriber = ElementDescriber.create(score, { partIndex: 0 }); - const frames = DefaultCursorFrame.create(log, score, timelines[0], { fromPartIndex: 0, toPartIndex: 0 }); + const frames = DefaultCursorFrame.create( + log, + score, + timelines[0], + { fromPartIndex: 0, toPartIndex: 0 }, + elementDescriber + ); expect(log.getLogs()).toBeEmpty(); expect(timelines).toHaveLength(1); @@ -77,8 +91,15 @@ describe(DefaultCursorFrame, () => { it('creates for: single measure, multiple staves, different notes', () => { const [score, timelines] = render('playback_multi_stave.musicxml'); + const elementDescriber = ElementDescriber.create(score, { partIndex: 0 }); - const frames = DefaultCursorFrame.create(log, score, timelines[0], { fromPartIndex: 0, toPartIndex: 0 }); + const frames = DefaultCursorFrame.create( + log, + score, + timelines[0], + { fromPartIndex: 0, toPartIndex: 0 }, + elementDescriber + ); expect(log.getLogs()).toBeEmpty(); expect(timelines).toHaveLength(1); @@ -129,13 +150,15 @@ describe(DefaultCursorFrame, () => { it('creates for: single measure, multiple staves, multiple parts', () => { const [score, timelines] = render('playback_multi_part.musicxml'); + const elementDescriber0 = ElementDescriber.create(score, { partIndex: 0 }); + const elementDescriber1 = ElementDescriber.create(score, { partIndex: 1 }); // This ends up adding test coverage for y-spans. const span0 = { fromPartIndex: 0, toPartIndex: 0 }; const span1 = { fromPartIndex: 0, toPartIndex: 1 }; - const framesPart0 = DefaultCursorFrame.create(log, score, timelines[0], span0); - const framesPart1 = DefaultCursorFrame.create(log, score, timelines[1], span1); + const framesPart0 = DefaultCursorFrame.create(log, score, timelines[0], span0, elementDescriber0); + const framesPart1 = DefaultCursorFrame.create(log, score, timelines[1], span1, elementDescriber1); expect(log.getLogs()).toBeEmpty(); expect(timelines).toHaveLength(2); @@ -189,8 +212,15 @@ describe(DefaultCursorFrame, () => { it('creates for: multiple measures, single stave, different notes', () => { const [score, timelines] = render('playback_multi_measure.musicxml'); + const elementDescriber = ElementDescriber.create(score, { partIndex: 0 }); - const frames = DefaultCursorFrame.create(log, score, timelines[0], { fromPartIndex: 0, toPartIndex: 0 }); + const frames = DefaultCursorFrame.create( + log, + score, + timelines[0], + { fromPartIndex: 0, toPartIndex: 0 }, + elementDescriber + ); expect(log.getLogs()).toBeEmpty(); expect(timelines).toHaveLength(1); @@ -240,8 +270,15 @@ describe(DefaultCursorFrame, () => { it('creates for: single measure, single stave, repeat', () => { const [score, timelines] = render('playback_repeat.musicxml'); + const elementDescriber = ElementDescriber.create(score, { partIndex: 0 }); - const frames = DefaultCursorFrame.create(log, score, timelines[0], { fromPartIndex: 0, toPartIndex: 0 }); + const frames = DefaultCursorFrame.create( + log, + score, + timelines[0], + { fromPartIndex: 0, toPartIndex: 0 }, + elementDescriber + ); expect(log.getLogs()).toBeEmpty(); expect(timelines).toHaveLength(1); @@ -291,8 +328,15 @@ describe(DefaultCursorFrame, () => { it('creates for: multiple measures, single stave, repeat with endings', () => { const [score, timelines] = render('playback_repeat_endings.musicxml'); + const elementDescriber = ElementDescriber.create(score, { partIndex: 0 }); - const frames = DefaultCursorFrame.create(log, score, timelines[0], { fromPartIndex: 0, toPartIndex: 0 }); + const frames = DefaultCursorFrame.create( + log, + score, + timelines[0], + { fromPartIndex: 0, toPartIndex: 0 }, + elementDescriber + ); expect(log.getLogs()).toBeEmpty(); expect(timelines).toHaveLength(1); @@ -332,8 +376,15 @@ describe(DefaultCursorFrame, () => { it('creates for: multiple measures, single stave, multiple systems', () => { const [score, timelines] = render('playback_multi_system.musicxml', { BASE_VOICE_WIDTH: 900 }); + const elementDescriber = ElementDescriber.create(score, { partIndex: 0 }); - const frames = DefaultCursorFrame.create(log, score, timelines[0], { fromPartIndex: 0, toPartIndex: 0 }); + const frames = DefaultCursorFrame.create( + log, + score, + timelines[0], + { fromPartIndex: 0, toPartIndex: 0 }, + elementDescriber + ); expect(log.getLogs()).toBeEmpty(); expect(timelines).toHaveLength(1); @@ -354,8 +405,15 @@ describe(DefaultCursorFrame, () => { it('creates for: documents that have backwards formatting', () => { const [score, timelines] = render('playback_backwards_formatting.musicxml'); - - const frames = DefaultCursorFrame.create(log, score, timelines[0], { fromPartIndex: 0, toPartIndex: 0 }); + const elementDescriber = ElementDescriber.create(score, { partIndex: 0 }); + + const frames = DefaultCursorFrame.create( + log, + score, + timelines[0], + { fromPartIndex: 0, toPartIndex: 0 }, + elementDescriber + ); expect(timelines).toHaveLength(1); expect(log.getLogs()).toBeEmpty(); From 172da743b2720a10064ecba5e3830b81f8c16d67 Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Mon, 14 Apr 2025 15:30:39 -0700 Subject: [PATCH 05/13] standardize timeline descriptions --- src/playback/elementdescriber.ts | 2 +- src/playback/timeline.ts | 27 ++--- tests/unit/playback/timeline.test.ts | 148 +++++++++++++-------------- 3 files changed, 82 insertions(+), 95 deletions(-) diff --git a/src/playback/elementdescriber.ts b/src/playback/elementdescriber.ts index 27f3a5c2b..70a6f7390 100644 --- a/src/playback/elementdescriber.ts +++ b/src/playback/elementdescriber.ts @@ -55,7 +55,7 @@ export class ElementDescriber { } private describeFragment(): string { - return '[fragment]'; + return 'fragment'; } private describeRest(rest: elements.Rest): string { diff --git a/src/playback/timeline.ts b/src/playback/timeline.ts index 0408fe2a7..8e85afbbd 100644 --- a/src/playback/timeline.ts +++ b/src/playback/timeline.ts @@ -4,6 +4,7 @@ import { PlaybackElement, TimelineMoment, TimelineMomentEvent, ElementTransition import * as elements from '@/elements'; import { LegacyMeasureSequenceIterator } from './legacymeasuresequenceiterator'; import * as util from '@/util'; +import { ElementDescriber } from './elementdescriber'; export class Timeline { constructor(private partIndex: number, private moments: TimelineMoment[], private describer: TimelineDescriber) {} @@ -12,7 +13,8 @@ export class 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(); + const elementDescriber = ElementDescriber.create(score, { partIndex }); + timelines[partIndex] = new TimelineFactory(log, score, partIndex).create(elementDescriber); } return timelines; } @@ -50,7 +52,7 @@ class TimelineFactory { constructor(private logger: Logger, private score: elements.Score, private partIndex: number) {} - create(): Timeline { + create(elementDescriber: ElementDescriber): Timeline { this.moments = new Map(); this.currentMeasureStartTime = Duration.zero(); @@ -58,7 +60,7 @@ class TimelineFactory { this.sortEventsWithinMoments(); const moments = this.getSortedMoments(); - const describer = TimelineDescriber.create(this.score, this.partIndex); + const describer = new TimelineDescriber(elementDescriber); return new Timeline(this.partIndex, moments, describer); } @@ -250,22 +252,7 @@ class TimelineFactory { } 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); - } + constructor(private elementDescriber: ElementDescriber) {} describe(moments: TimelineMoment[]): string[] { return moments.map((moment) => this.describeMoment(moment)); @@ -287,7 +274,7 @@ class TimelineDescriber { } private describeTransition(event: ElementTransitionEvent): string { - return `${event.kind}(${this.elements.get(event.element)})`; + return `${event.kind}(${this.elementDescriber.describe(event.element)})`; } private describeJump(): string { diff --git a/tests/unit/playback/timeline.test.ts b/tests/unit/playback/timeline.test.ts index 52b5f9099..79d59d87b 100644 --- a/tests/unit/playback/timeline.test.ts +++ b/tests/unit/playback/timeline.test.ts @@ -17,11 +17,11 @@ describe(Timeline, () => { 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', + '[0ms] start(element(0))', + '[600ms] stop(element(0)), start(element(1))', + '[1200ms] stop(element(1)), start(element(2))', + '[1800ms] stop(element(2)), start(element(3))', + '[2400ms] stop(element(3)), systemend', ]); }); @@ -33,11 +33,11 @@ describe(Timeline, () => { 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', + '[0ms] start(element(0))', + '[600ms] stop(element(0)), start(element(1))', + '[1200ms] stop(element(1)), start(element(2))', + '[1800ms] stop(element(2)), start(element(3))', + '[2400ms] stop(element(3)), systemend', ]); }); @@ -50,15 +50,15 @@ describe(Timeline, () => { 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', + '[0ms] start(element(0)), start(element(4))', + '[300ms] stop(element(4)), start(element(5))', + '[600ms] stop(element(0)), stop(element(5)), start(element(1)), start(element(6))', + '[900ms] stop(element(6)), start(element(7))', + '[1200ms] stop(element(1)), stop(element(7)), start(element(2)), start(element(8))', + '[1500ms] stop(element(8)), start(element(9))', + '[1800ms] stop(element(2)), stop(element(9)), start(element(3)), start(element(10))', + '[2100ms] stop(element(10)), start(element(11))', + '[2400ms] stop(element(3)), stop(element(11)), systemend', ]); }); @@ -70,20 +70,20 @@ describe(Timeline, () => { 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', + '[0ms] start(element(0))', + '[600ms] stop(element(0)), start(element(1))', + '[1200ms] stop(element(1)), start(element(2))', + '[1800ms] stop(element(2)), start(element(3))', + '[2400ms] stop(element(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', + '[0ms] start(element(0)), start(element(4))', + '[600ms] stop(element(0)), stop(element(4)), start(element(1)), start(element(5))', + '[1200ms] stop(element(1)), stop(element(5)), start(element(2)), start(element(6))', + '[1800ms] stop(element(2)), stop(element(6)), start(element(3)), start(element(7))', + '[2400ms] stop(element(3)), stop(element(7)), systemend', ]); }); @@ -95,15 +95,15 @@ describe(Timeline, () => { 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', + '[0ms] start(element(0))', + '[600ms] stop(element(0)), start(element(1))', + '[1200ms] stop(element(1)), start(element(2))', + '[1800ms] stop(element(2)), start(element(3))', + '[2400ms] stop(element(3)), start(element(4))', + '[3000ms] stop(element(4)), start(element(5))', + '[3600ms] stop(element(5)), start(element(6))', + '[4200ms] stop(element(6)), start(element(7))', + '[4800ms] stop(element(7)), systemend', ]); }); @@ -115,15 +115,15 @@ describe(Timeline, () => { 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', + '[0ms] start(element(0))', + '[600ms] stop(element(0)), start(element(1))', + '[1200ms] stop(element(1)), start(element(2))', + '[1800ms] stop(element(2)), start(element(3))', + '[2400ms] stop(element(3)), jump, start(element(0))', + '[3000ms] stop(element(0)), start(element(1))', + '[3600ms] stop(element(1)), start(element(2))', + '[4200ms] stop(element(2)), start(element(3))', + '[4800ms] stop(element(3)), systemend', ]); }); @@ -135,13 +135,13 @@ describe(Timeline, () => { 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', + '[0ms] start(element(0))', + '[2400ms] stop(element(0)), start(element(1))', + '[4800ms] stop(element(1)), jump, start(element(0))', + '[7200ms] stop(element(0)), jump, start(element(2))', + '[9600ms] stop(element(2)), jump, start(element(0))', + '[12000ms] stop(element(0)), jump, start(element(3))', + '[14400ms] stop(element(3)), systemend', ]); }); @@ -154,9 +154,9 @@ describe(Timeline, () => { expect(timelines[0].toHumanReadable()).toEqual([ // system0, stave0: 0 // system1, stave0: 1 - '[0ms] start(0)', - '[2400ms] stop(0), systemend, start(1)', - '[4800ms] stop(1), systemend', + '[0ms] start(element(0))', + '[2400ms] stop(element(0)), systemend, start(element(1))', + '[4800ms] stop(element(1)), systemend', ]); }); @@ -170,23 +170,23 @@ describe(Timeline, () => { // stave0, voice0: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 // stave1, voice0: 14 15 16 17 18 19 // stave2, voice1: 20 21 - '[0ms] start(0), start(14), start(20)', - '[150ms] stop(14), start(15)', - '[300ms] stop(0), start(1)', - '[450ms] stop(1), start(2)', - '[600ms] stop(2), stop(15), start(3), start(16)', - '[750ms] stop(3), start(4)', - '[900ms] stop(4), start(5)', - '[1050ms] stop(5), start(6)', - '[1200ms] stop(6), stop(16), stop(20), start(7), start(17), start(21)', - '[1350ms] stop(17), start(18)', - '[1500ms] stop(7), start(8)', - '[1650ms] stop(8), start(9)', - '[1800ms] stop(9), stop(18), start(10), start(19)', - '[1950ms] stop(10), start(11)', - '[2100ms] stop(11), start(12)', - '[2250ms] stop(12), start(13)', - '[2400ms] stop(13), stop(19), stop(21), systemend', + '[0ms] start(element(0)), start(element(14)), start(element(20))', + '[150ms] stop(element(14)), start(element(15))', + '[300ms] stop(element(0)), start(element(1))', + '[450ms] stop(element(1)), start(element(2))', + '[600ms] stop(element(2)), stop(element(15)), start(element(3)), start(element(16))', + '[750ms] stop(element(3)), start(element(4))', + '[900ms] stop(element(4)), start(element(5))', + '[1050ms] stop(element(5)), start(element(6))', + '[1200ms] stop(element(6)), stop(element(16)), stop(element(20)), start(element(7)), start(element(17)), start(element(21))', + '[1350ms] stop(element(17)), start(element(18))', + '[1500ms] stop(element(7)), start(element(8))', + '[1650ms] stop(element(8)), start(element(9))', + '[1800ms] stop(element(9)), stop(element(18)), start(element(10)), start(element(19))', + '[1950ms] stop(element(10)), start(element(11))', + '[2100ms] stop(element(11)), start(element(12))', + '[2250ms] stop(element(12)), start(element(13))', + '[2400ms] stop(element(13)), stop(element(19)), stop(element(21)), systemend', ]); }); }); From ceb4fbc1862a5782fa0ab31860f1c6c9b1a824a4 Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Thu, 1 May 2025 07:29:18 -0400 Subject: [PATCH 06/13] create HintDescriber and use it to start testing LazyCursorStateHintProvider --- src/elements/score.ts | 2 +- src/playback/cursor.ts | 18 +- src/playback/elementdescriber.ts | 4 + src/playback/hintdescriber.ts | 27 +++ src/playback/index.ts | 1 + src/playback/lazycursorstatehintprovider.ts | 19 +- src/playback/types.ts | 1 + .../lazycursorstatehintprovider.test.ts | 209 +++++++++++------- 8 files changed, 185 insertions(+), 96 deletions(-) create mode 100644 src/playback/hintdescriber.ts diff --git a/src/elements/score.ts b/src/elements/score.ts index f7032da61..699b22d61 100644 --- a/src/elements/score.ts +++ b/src/elements/score.ts @@ -81,7 +81,7 @@ export class Score { const elementDescriber = playback.ElementDescriber.create(this, { partIndex }); const frames = playback.DefaultCursorFrame.create(this.log, this, timeline, span, elementDescriber); const path = new playback.CursorPath(partIndex, frames); - const cursor = playback.Cursor.create(path, this.getScrollContainer()); + const cursor = playback.Cursor.create(path, this.getScrollContainer(), elementDescriber); this.cursors.push(cursor); diff --git a/src/playback/cursor.ts b/src/playback/cursor.ts index 46e2da45c..f29ed14f3 100644 --- a/src/playback/cursor.ts +++ b/src/playback/cursor.ts @@ -9,6 +9,8 @@ import { Duration } from './duration'; import { CursorPath } from './cursorpath'; import { LazyCursorStateHintProvider } from './lazycursorstatehintprovider'; import { EmptyCursorFrame } from './emptycursorframe'; +import { ElementDescriber } from './elementdescriber'; +import { HintDescriber } from './hintdescriber'; // 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. @@ -35,18 +37,23 @@ export class Cursor { private previousFrame: CursorFrame = new EmptyCursorFrame(); - private constructor(private path: CursorPath, private locator: CursorFrameLocator, private scroller: Scroller) {} + private constructor( + private path: CursorPath, + private locator: CursorFrameLocator, + private scroller: Scroller, + private elementDescriber: ElementDescriber + ) {} - static create(path: CursorPath, scrollContainer: HTMLElement): Cursor { + static create(path: CursorPath, scrollContainer: HTMLElement, elementDescriber: ElementDescriber): Cursor { const bSearchLocator = new BSearchCursorFrameLocator(path); const fastLocator = new FastCursorFrameLocator(path, bSearchLocator); const scroller = new Scroller(scrollContainer); - return new Cursor(path, fastLocator, scroller); + return new Cursor(path, fastLocator, scroller, elementDescriber); } iterable(): Iterable { // Clone the cursor to avoid modifying the index of this instance. - const cursor = new Cursor(this.path, this.locator, this.scroller); + const cursor = new Cursor(this.path, this.locator, this.scroller, this.elementDescriber); return new CursorIterator(cursor); } @@ -56,7 +63,8 @@ export class Cursor { const hasPrevious = index > 0; const frame = this.getCurrentFrame(); const rect = this.getCursorRect(frame, this.alpha); - const hints = new LazyCursorStateHintProvider(frame, this.previousFrame); + const hintDescriber = new HintDescriber(this.elementDescriber); + const hints = new LazyCursorStateHintProvider(frame, this.previousFrame, hintDescriber); return { index, diff --git a/src/playback/elementdescriber.ts b/src/playback/elementdescriber.ts index 70a6f7390..ddc768883 100644 --- a/src/playback/elementdescriber.ts +++ b/src/playback/elementdescriber.ts @@ -8,6 +8,10 @@ import * as util from '@/util'; export class ElementDescriber { private constructor(private elements: Map) {} + static noop(): ElementDescriber { + return new ElementDescriber(new Map()); + } + static create(score: elements.Score, { partIndex }: { partIndex: number }): ElementDescriber { const elements = new Map(); diff --git a/src/playback/hintdescriber.ts b/src/playback/hintdescriber.ts new file mode 100644 index 000000000..5e154fe07 --- /dev/null +++ b/src/playback/hintdescriber.ts @@ -0,0 +1,27 @@ +import { ElementDescriber } from './elementdescriber'; +import { CursorStateHint } from './types'; + +export class HintDescriber { + constructor(private elementDescriber: ElementDescriber) {} + + static noop(): HintDescriber { + return new HintDescriber(ElementDescriber.noop()); + } + + describe(hint: CursorStateHint): string { + switch (hint.type) { + case 'start': + return `start(${this.elementDescriber.describe(hint.element)})`; + case 'stop': + return `stop(${this.elementDescriber.describe(hint.element)})`; + case 'retrigger': + return `retrigger(${this.elementDescriber.describe(hint.untriggerElement)}, ${this.elementDescriber.describe( + hint.retriggerElement + )})`; + case 'sustain': + return `sustain(${this.elementDescriber.describe(hint.previousElement)}, ${this.elementDescriber.describe( + hint.currentElement + )})`; + } + } +} diff --git a/src/playback/index.ts b/src/playback/index.ts index 16d71acf6..71a62240a 100644 --- a/src/playback/index.ts +++ b/src/playback/index.ts @@ -9,3 +9,4 @@ export * from './defaultcursorframe'; export * from './lazycursorstatehintprovider'; export * from './emptycursorframe'; export * from './elementdescriber'; +export * from './hintdescriber'; diff --git a/src/playback/lazycursorstatehintprovider.ts b/src/playback/lazycursorstatehintprovider.ts index 413dbce4f..ab779fae8 100644 --- a/src/playback/lazycursorstatehintprovider.ts +++ b/src/playback/lazycursorstatehintprovider.ts @@ -10,9 +10,14 @@ import { StopHint, SustainHint, } from './types'; +import { HintDescriber } from './hintdescriber'; export class LazyCursorStateHintProvider implements CursorStateHintProvider { - constructor(private currentFrame: CursorFrame, private previousFrame: CursorFrame | undefined) {} + constructor( + private currentFrame: CursorFrame, + private previousFrame: CursorFrame | undefined, + private hintDescriber: HintDescriber + ) {} @util.memoize() get(): CursorStateHint[] { @@ -30,14 +35,18 @@ export class LazyCursorStateHintProvider implements CursorStateHintProvider { const currentNotes = this.currentFrame.getActiveElements().filter((e) => e.name === 'note'); return [ - ...this.getStartHints(currentElements, previousElements), ...this.getStopHints(currentElements, previousElements), + ...this.getStartHints(currentElements, previousElements), ...this.getRetriggerHints(currentNotes, previousNotes), ...this.getSustainHints(currentNotes, previousNotes), ]; } - private getStartHints(previousElements: Set, currentElements: Set): StartHint[] { + toHumanReadable(): string[] { + return this.get().map((hint) => this.hintDescriber.describe(hint)); + } + + private getStartHints(currentElements: Set, previousElements: Set): StartHint[] { const hints = new Array(); for (const element of currentElements) { @@ -49,7 +58,7 @@ export class LazyCursorStateHintProvider implements CursorStateHintProvider { return hints; } - private getStopHints(previousElements: Set, currentElements: Set): StopHint[] { + private getStopHints(currentElements: Set, previousElements: Set): StopHint[] { const hints = new Array(); for (const element of previousElements) { @@ -66,7 +75,7 @@ export class LazyCursorStateHintProvider implements CursorStateHintProvider { for (const currentNote of currentNotes) { const previousNote = previousNotes.find((previousNote) => previousNote.containsEquivalentPitch(currentNote)); - if (previousNote && !previousNote.sharesACurveWith(currentNote)) { + if (previousNote && previousNote !== currentNote && !previousNote.sharesACurveWith(currentNote)) { hints.push({ type: 'retrigger', untriggerElement: previousNote, diff --git a/src/playback/types.ts b/src/playback/types.ts index c23d634e7..f0e068491 100644 --- a/src/playback/types.ts +++ b/src/playback/types.ts @@ -51,6 +51,7 @@ export type SystemEndEvent = { export interface CursorStateHintProvider { get(): CursorStateHint[]; + toHumanReadable(): string[]; } export type CursorStateHint = StartHint | StopHint | RetriggerHint | SustainHint; diff --git a/tests/unit/playback/lazycursorstatehintprovider.test.ts b/tests/unit/playback/lazycursorstatehintprovider.test.ts index fce438d1a..b76826cc7 100644 --- a/tests/unit/playback/lazycursorstatehintprovider.test.ts +++ b/tests/unit/playback/lazycursorstatehintprovider.test.ts @@ -1,19 +1,17 @@ import * as vexml from '@/index'; import * as path from 'path'; import fs from 'fs'; -import { EmptyCursorFrame, LazyCursorStateHintProvider } from '@/playback'; +import { EmptyCursorFrame, HintDescriber, 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 hintDescriber = HintDescriber.noop(); - const provider = new LazyCursorStateHintProvider(currentFrame, previousFrame); + const provider = new LazyCursorStateHintProvider(currentFrame, previousFrame, hintDescriber); expect(() => provider.get()).not.toThrow(); }); @@ -21,106 +19,147 @@ describe(LazyCursorStateHintProvider, () => { it('does not throw when there are two empty frames', () => { const currentFrame = new EmptyCursorFrame(); const previousFrame = new EmptyCursorFrame(); + const hintDescriber = HintDescriber.noop(); - const provider = new LazyCursorStateHintProvider(currentFrame, previousFrame); + const provider = new LazyCursorStateHintProvider(currentFrame, previousFrame, hintDescriber); expect(() => provider.get()).not.toThrow(); }); - it('does not throw for: single measure, single stave, different notes', () => { + it('provides 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(); - } + const states = Array.from(cursor.iterable()); + + expect(states).toHaveLength(3); + // stave0: 0 1 2 3 + expect(states[0].hints.toHumanReadable()).toEqual(['start(element(0))']); + expect(states[1].hints.toHumanReadable()).toEqual(['stop(element(0))', 'start(element(1))']); + expect(states[2].hints.toHumanReadable()).toEqual(['stop(element(1))', 'start(element(2))']); }); - it('does not throw for: single measure, single stave, same notes', () => { + it('provides 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(); - } + const states = Array.from(cursor.iterable()); + + expect(states).toHaveLength(3); + // stave0: 0 1 2 3 + expect(states[0].hints.toHumanReadable()).toEqual(['start(element(0))']); + expect(states[1].hints.toHumanReadable()).toEqual([ + 'stop(element(0))', + 'start(element(1))', + 'retrigger(element(0), element(1))', + ]); + expect(states[2].hints.toHumanReadable()).toEqual([ + 'stop(element(1))', + 'start(element(2))', + 'retrigger(element(1), element(2))', + ]); }); - it('does not throw for: single measure, multiple staves, different notes', () => { + it('provides 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(); - } + const states = Array.from(cursor.iterable()); + + expect(states).toHaveLength(7); + // stave0: 0 1 2 3 + // stave1: 4 5 6 7 8 9 10 11 + expect(states[0].hints.toHumanReadable()).toEqual(['start(element(0))', 'start(element(4))']); + expect(states[1].hints.toHumanReadable()).toEqual(['stop(element(4))', 'start(element(5))']); + expect(states[2].hints.toHumanReadable()).toEqual([ + 'stop(element(0))', + 'stop(element(5))', + 'start(element(1))', + 'start(element(6))', + ]); + expect(states[3].hints.toHumanReadable()).toEqual(['stop(element(6))', 'start(element(7))']); + expect(states[4].hints.toHumanReadable()).toEqual([ + 'stop(element(1))', + 'stop(element(7))', + 'start(element(2))', + 'start(element(8))', + ]); + expect(states[5].hints.toHumanReadable()).toEqual(['stop(element(8))', 'start(element(9))']); + expect(states[6].hints.toHumanReadable()).toEqual([ + 'stop(element(2))', + 'stop(element(9))', + 'start(element(3))', + 'start(element(10))', + ]); }); - 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(); - } - }); + // it('provides 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('provides 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('provides 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('provides 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('provides 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('provides 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('provides 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 { From c6473dc985fa9cfbb1061db1ef302990b77942ff Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Thu, 1 May 2025 07:39:19 -0400 Subject: [PATCH 07/13] test LazyCursorStateHintProvider provides for: single measure, multiple staves, multiple parts --- .../lazycursorstatehintprovider.test.ts | 40 ++++++++++++++----- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/tests/unit/playback/lazycursorstatehintprovider.test.ts b/tests/unit/playback/lazycursorstatehintprovider.test.ts index b76826cc7..fc5043974 100644 --- a/tests/unit/playback/lazycursorstatehintprovider.test.ts +++ b/tests/unit/playback/lazycursorstatehintprovider.test.ts @@ -93,19 +93,37 @@ describe(LazyCursorStateHintProvider, () => { ]); }); - // it('provides 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 }); + it('provides 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(); - // } + const states0 = Array.from(cursor0.iterable()); + const states1 = Array.from(cursor1.iterable()); - // for (const state of cursor1.iterable()) { - // expect(() => state.hints.get()).not.toThrow(); - // } - // }); + expect(states0).toHaveLength(3); + // stave0: 0 1 2 3 + expect(states0[0].hints.toHumanReadable()).toEqual(['start(element(0))']); + expect(states0[1].hints.toHumanReadable()).toEqual(['stop(element(0))', 'start(element(1))']); + expect(states0[2].hints.toHumanReadable()).toEqual(['stop(element(1))', 'start(element(2))']); + + expect(states1).toHaveLength(3); + // stave0: 0 1 2 3 + // stave1: 4 5 6 7 + expect(states1[0].hints.toHumanReadable()).toEqual(['start(element(0))', 'start(element(4))']); + expect(states1[1].hints.toHumanReadable()).toEqual([ + 'stop(element(0))', + 'stop(element(4))', + 'start(element(1))', + 'start(element(5))', + ]); + expect(states1[2].hints.toHumanReadable()).toEqual([ + 'stop(element(1))', + 'stop(element(5))', + 'start(element(2))', + 'start(element(6))', + ]); + }); // it('provides for: multiple measures, single stave, different notes', () => { // const score = render('playback_multi_measure.musicxml'); From 258f4e116fa49f02978c9fd4fda9f323069d63c2 Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Thu, 1 May 2025 07:41:18 -0400 Subject: [PATCH 08/13] test LazyCursorStateHintProvider provides for: multiple measures, single stave, different notes --- .../lazycursorstatehintprovider.test.ts | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/tests/unit/playback/lazycursorstatehintprovider.test.ts b/tests/unit/playback/lazycursorstatehintprovider.test.ts index fc5043974..6e4cda123 100644 --- a/tests/unit/playback/lazycursorstatehintprovider.test.ts +++ b/tests/unit/playback/lazycursorstatehintprovider.test.ts @@ -125,14 +125,21 @@ describe(LazyCursorStateHintProvider, () => { ]); }); - // it('provides for: multiple measures, single stave, different notes', () => { - // const score = render('playback_multi_measure.musicxml'); - // const cursor = score.addCursor(); + it('provides 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(); - // } - // }); + const states = Array.from(cursor.iterable()); + expect(states).toHaveLength(7); + // stave0: 0 1 2 3 4 | 5 6 7 8 + expect(states[0].hints.toHumanReadable()).toEqual(['start(element(0))']); + expect(states[1].hints.toHumanReadable()).toEqual(['stop(element(0))', 'start(element(1))']); + expect(states[2].hints.toHumanReadable()).toEqual(['stop(element(1))', 'start(element(2))']); + expect(states[3].hints.toHumanReadable()).toEqual(['stop(element(2))', 'start(element(3))']); + expect(states[4].hints.toHumanReadable()).toEqual(['stop(element(3))', 'start(element(4))']); + expect(states[5].hints.toHumanReadable()).toEqual(['stop(element(4))', 'start(element(5))']); + expect(states[6].hints.toHumanReadable()).toEqual(['stop(element(5))', 'start(element(6))']); + }); // it('provides for: single measure, single stave, repeat', () => { // const score = render('playback_repeat.musicxml'); From bb1707313a9455f2f2b2a88e963b260b5114ab25 Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Thu, 1 May 2025 07:44:58 -0400 Subject: [PATCH 09/13] test LazyCursorStateHintProvider provides for: single measure, single stave, repeat --- .../lazycursorstatehintprovider.test.ts | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/tests/unit/playback/lazycursorstatehintprovider.test.ts b/tests/unit/playback/lazycursorstatehintprovider.test.ts index 6e4cda123..7284cfe04 100644 --- a/tests/unit/playback/lazycursorstatehintprovider.test.ts +++ b/tests/unit/playback/lazycursorstatehintprovider.test.ts @@ -130,6 +130,7 @@ describe(LazyCursorStateHintProvider, () => { const cursor = score.addCursor(); const states = Array.from(cursor.iterable()); + expect(states).toHaveLength(7); // stave0: 0 1 2 3 4 | 5 6 7 8 expect(states[0].hints.toHumanReadable()).toEqual(['start(element(0))']); @@ -141,14 +142,22 @@ describe(LazyCursorStateHintProvider, () => { expect(states[6].hints.toHumanReadable()).toEqual(['stop(element(5))', 'start(element(6))']); }); - // it('provides for: single measure, single stave, repeat', () => { - // const score = render('playback_repeat.musicxml'); - // const cursor = score.addCursor(); + it('provides 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(); - // } - // }); + const states = Array.from(cursor.iterable()); + + expect(states).toHaveLength(7); + // stave0: 0 1 2 3 :|| + expect(states[0].hints.toHumanReadable()).toEqual(['start(element(0))']); + expect(states[1].hints.toHumanReadable()).toEqual(['stop(element(0))', 'start(element(1))']); + expect(states[2].hints.toHumanReadable()).toEqual(['stop(element(1))', 'start(element(2))']); + expect(states[3].hints.toHumanReadable()).toEqual(['stop(element(2))', 'start(element(3))']); + expect(states[4].hints.toHumanReadable()).toEqual(['stop(element(3))', 'start(element(0))']); + expect(states[5].hints.toHumanReadable()).toEqual(['stop(element(0))', 'start(element(1))']); + expect(states[6].hints.toHumanReadable()).toEqual(['stop(element(1))', 'start(element(2))']); + }); // it('provides for: multiple measures, single stave, repeat with endings', () => { // const score = render('playback_repeat_endings.musicxml'); From 08c59e23f901c7be2872c198089604e7aeb56190 Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Thu, 1 May 2025 07:50:24 -0400 Subject: [PATCH 10/13] test LazyCursorStateHintProvider provides for: multiple measures, single stave, repeat with endings --- .../lazycursorstatehintprovider.test.ts | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/tests/unit/playback/lazycursorstatehintprovider.test.ts b/tests/unit/playback/lazycursorstatehintprovider.test.ts index 7284cfe04..122fc92d7 100644 --- a/tests/unit/playback/lazycursorstatehintprovider.test.ts +++ b/tests/unit/playback/lazycursorstatehintprovider.test.ts @@ -159,14 +159,36 @@ describe(LazyCursorStateHintProvider, () => { expect(states[6].hints.toHumanReadable()).toEqual(['stop(element(1))', 'start(element(2))']); }); - // it('provides for: multiple measures, single stave, repeat with endings', () => { - // const score = render('playback_repeat_endings.musicxml'); - // const cursor = score.addCursor(); + it('provides 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(); - // } - // }); + const states = Array.from(cursor.iterable()); + + expect(states).toHaveLength(5); + // stave0: 0 | [ending1 -> 1] :|| [ending2 -> 2] :|| [ending3 -> 3] + expect(states[0].hints.toHumanReadable()).toEqual(['start(element(0))']); + expect(states[1].hints.toHumanReadable()).toEqual([ + 'stop(element(0))', + 'start(element(1))', + 'retrigger(element(0), element(1))', + ]); + expect(states[2].hints.toHumanReadable()).toEqual([ + 'stop(element(1))', + 'start(element(0))', + 'retrigger(element(1), element(0))', + ]); + expect(states[3].hints.toHumanReadable()).toEqual([ + 'stop(element(0))', + 'start(element(2))', + 'retrigger(element(0), element(2))', + ]); + expect(states[4].hints.toHumanReadable()).toEqual([ + 'stop(element(2))', + 'start(element(0))', + 'retrigger(element(2), element(0))', + ]); + }); // it('provides for: multiple measures, single stave, multiple systems', () => { // const score = render('playback_multi_system.musicxml', { BASE_VOICE_WIDTH: 900 }); From 74eddb7752abd9b0019022331588dbb3313c682f Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Thu, 1 May 2025 07:51:20 -0400 Subject: [PATCH 11/13] test LazyCursorStateHintProvider provides for: multiple measures, single stave, multiple systems --- .../lazycursorstatehintprovider.test.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/tests/unit/playback/lazycursorstatehintprovider.test.ts b/tests/unit/playback/lazycursorstatehintprovider.test.ts index 122fc92d7..31a43d2fb 100644 --- a/tests/unit/playback/lazycursorstatehintprovider.test.ts +++ b/tests/unit/playback/lazycursorstatehintprovider.test.ts @@ -190,14 +190,17 @@ describe(LazyCursorStateHintProvider, () => { ]); }); - // it('provides for: multiple measures, single stave, multiple systems', () => { - // const score = render('playback_multi_system.musicxml', { BASE_VOICE_WIDTH: 900 }); - // const cursor = score.addCursor(); + it('provides 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(); - // } - // }); + const states = Array.from(cursor.iterable()); + + expect(states).toHaveLength(1); + // system0, stave0: 0 + // system1, stave0: 1 + expect(states[0].hints.toHumanReadable()).toEqual(['start(element(0))']); + }); // it('provides for: documents that have backwards formatting', () => { // const score = render('playback_backwards_formatting.musicxml'); From 99017d90c73815190862c4ac26a7b74d615ebd53 Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Thu, 1 May 2025 08:08:02 -0400 Subject: [PATCH 12/13] test LazyCursorStateHintProvider provides for: documents that have backwards formatting --- src/playback/lazycursorstatehintprovider.ts | 2 +- .../lazycursorstatehintprovider.test.ts | 56 ++++++++++++++++--- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/src/playback/lazycursorstatehintprovider.ts b/src/playback/lazycursorstatehintprovider.ts index ab779fae8..63ae907b4 100644 --- a/src/playback/lazycursorstatehintprovider.ts +++ b/src/playback/lazycursorstatehintprovider.ts @@ -92,7 +92,7 @@ export class LazyCursorStateHintProvider implements CursorStateHintProvider { for (const currentNote of currentNotes) { const previousNote = previousNotes.find((previousNote) => previousNote.containsEquivalentPitch(currentNote)); - if (previousNote && previousNote.sharesACurveWith(currentNote)) { + if (previousNote && previousNote !== currentNote && previousNote.sharesACurveWith(currentNote)) { hints.push({ type: 'sustain', previousElement: previousNote, diff --git a/tests/unit/playback/lazycursorstatehintprovider.test.ts b/tests/unit/playback/lazycursorstatehintprovider.test.ts index 31a43d2fb..09d97a38d 100644 --- a/tests/unit/playback/lazycursorstatehintprovider.test.ts +++ b/tests/unit/playback/lazycursorstatehintprovider.test.ts @@ -202,14 +202,56 @@ describe(LazyCursorStateHintProvider, () => { expect(states[0].hints.toHumanReadable()).toEqual(['start(element(0))']); }); - // it('provides for: documents that have backwards formatting', () => { - // const score = render('playback_backwards_formatting.musicxml'); - // const cursor = score.addCursor(); + it('provides 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(); - // } - // }); + const states = Array.from(cursor.iterable()); + + expect(states).toHaveLength(15); + // stave0, voice0: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 + // stave1, voice0: 14 15 16 17 18 19 + // stave2, voice1: 20 21 + expect(states[0].hints.toHumanReadable()).toEqual([ + 'start(element(0))', + 'start(element(14))', + 'start(element(20))', + ]); + expect(states[1].hints.toHumanReadable()).toEqual(['stop(element(14))', 'start(element(15))']); + expect(states[2].hints.toHumanReadable()).toEqual(['stop(element(0))', 'start(element(1))']); + expect(states[3].hints.toHumanReadable()).toEqual(['stop(element(1))', 'start(element(2))']); + expect(states[4].hints.toHumanReadable()).toEqual([ + 'stop(element(15))', + 'stop(element(2))', + 'start(element(3))', + 'start(element(16))', + 'sustain(element(15), element(16))', + ]); + expect(states[5].hints.toHumanReadable()).toEqual(['stop(element(3))', 'start(element(4))']); + expect(states[6].hints.toHumanReadable()).toEqual(['stop(element(4))', 'start(element(5))']); + expect(states[7].hints.toHumanReadable()).toEqual(['stop(element(5))', 'start(element(6))']); + expect(states[8].hints.toHumanReadable()).toEqual([ + 'stop(element(20))', + 'stop(element(16))', + 'stop(element(6))', + 'start(element(7))', + 'start(element(17))', + 'start(element(21))', + 'retrigger(element(20), element(21))', + ]); + expect(states[9].hints.toHumanReadable()).toEqual(['stop(element(17))', 'start(element(18))']); + expect(states[10].hints.toHumanReadable()).toEqual(['stop(element(7))', 'start(element(8))']); + expect(states[11].hints.toHumanReadable()).toEqual(['stop(element(8))', 'start(element(9))']); + expect(states[12].hints.toHumanReadable()).toEqual([ + 'stop(element(18))', + 'stop(element(9))', + 'start(element(10))', + 'start(element(19))', + 'sustain(element(18), element(19))', + ]); + expect(states[13].hints.toHumanReadable()).toEqual(['stop(element(10))', 'start(element(11))']); + expect(states[14].hints.toHumanReadable()).toEqual(['stop(element(11))', 'start(element(12))']); + }); // it('provides for: chords', () => { // const score = render('playback_chords.musicxml'); From c3461b999a78d3ed9efe8f54e2a8c15adbd12912 Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Thu, 1 May 2025 08:14:31 -0400 Subject: [PATCH 13/13] test LazyCursorStateHintProvider provides for: chords --- .../lazycursorstatehintprovider.test.ts | 49 ++++++++++++++++--- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/tests/unit/playback/lazycursorstatehintprovider.test.ts b/tests/unit/playback/lazycursorstatehintprovider.test.ts index 09d97a38d..b3237027a 100644 --- a/tests/unit/playback/lazycursorstatehintprovider.test.ts +++ b/tests/unit/playback/lazycursorstatehintprovider.test.ts @@ -253,14 +253,47 @@ describe(LazyCursorStateHintProvider, () => { expect(states[14].hints.toHumanReadable()).toEqual(['stop(element(11))', 'start(element(12))']); }); - // it('provides for: chords', () => { - // const score = render('playback_chords.musicxml'); - // const cursor = score.addCursor(); - - // for (const state of cursor.iterable()) { - // expect(() => state.hints.get()).not.toThrow(); - // } - // }); + it('provides for: chords', () => { + const score = render('playback_chords.musicxml'); + const cursor0 = score.addCursor({ partIndex: 0 }); + const cursor1 = score.addCursor({ partIndex: 1 }); + + const states0 = Array.from(cursor0.iterable()); + const states1 = Array.from(cursor1.iterable()); + + expect(states0).toHaveLength(4); + // NOTE: There are rests that overlap with the notes. + expect(states0[0].hints.toHumanReadable()).toEqual(['start(element(0))', 'start(element(3))']); + expect(states0[1].hints.toHumanReadable()).toEqual([ + 'stop(element(0))', + 'stop(element(3))', + 'start(element(1))', + 'start(element(4))', + 'start(element(7))', + 'retrigger(element(0), element(1))', + ]); + expect(states0[2].hints.toHumanReadable()).toEqual(['stop(element(7))', 'start(element(8))']); + expect(states0[3].hints.toHumanReadable()).toEqual([ + 'stop(element(1))', + 'stop(element(4))', + 'stop(element(8))', + 'start(element(2))', + 'start(element(5))', + 'start(element(9))', + ]); + + expect(states1).toHaveLength(4); + // NOTE: There are rests that overlap with the notes. + expect(states1[0].hints.toHumanReadable()).toEqual(['start(element(0))']); + expect(states1[1].hints.toHumanReadable()).toEqual(['stop(element(0))', 'start(element(1))', 'start(element(4))']); + expect(states1[2].hints.toHumanReadable()).toEqual(['stop(element(4))', 'start(element(5))']); + expect(states1[3].hints.toHumanReadable()).toEqual([ + 'stop(element(1))', + 'stop(element(5))', + 'start(element(2))', + 'start(element(6))', + ]); + }); }); function render(filename: string, config?: Partial): vexml.Score {