From 1d35323ec956836ba87aa0e9409789b664b71d33 Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Sun, 16 Mar 2025 18:17:07 -0400 Subject: [PATCH 01/38] update vexflow to 5.0.0 --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3439b32f2..bf2879369 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "jszip": "3.10.1", - "vexflow": "5.0.0-beta.1" + "vexflow": "5.0.0" }, "devDependencies": { "@babel/core": "^7.17.8", @@ -13183,9 +13183,9 @@ } }, "node_modules/vexflow": { - "version": "5.0.0-beta.1", - "resolved": "https://registry.npmjs.org/vexflow/-/vexflow-5.0.0-beta.1.tgz", - "integrity": "sha512-MDnkHmslgY/LE8rINmq9aQmr11lkphHxJ/4iv47os/2TSV2XY24CRRcHcoQp+wACHDfZmkmj9wbvRU6vFWThgQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/vexflow/-/vexflow-5.0.0.tgz", + "integrity": "sha512-rjB7TV4ygKE5Fl3W5OlG+0dHv22CFufUJdMG6oNgvcn0zp34u+sOboZsadQXnF1O3tZ3myXThaUIaLkJlpNM2Q==", "license": "MIT" }, "node_modules/vexflow-fonts": { diff --git a/package.json b/package.json index f9c82d24a..e403239be 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ }, "dependencies": { "jszip": "3.10.1", - "vexflow": "5.0.0-beta.1" + "vexflow": "5.0.0" }, "devDependencies": { "@babel/core": "^7.17.8", From e78e0077ad8396d11bed5a419b726f8ed98029a9 Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Mon, 17 Mar 2025 06:47:45 -0400 Subject: [PATCH 02/38] add playback test suite --- .../vexml/playback_multi_measure.musicxml | 139 ++++++++++ .../vexml/playback_multi_staff.musicxml | 205 +++++++++++++++ .../vexml/playback_multi_system.musicxml | 154 ++++++++++++ .../vexml/playback_repeat_endings.musicxml | 237 ++++++++++++++++++ .../vexml/playback_repeated_note.musicxml | 97 +++++++ tests/__data__/vexml/playback_simple.musicxml | 97 +++++++ .../vexml/playback_simple_repeat.musicxml | 98 ++++++++ 7 files changed, 1027 insertions(+) create mode 100644 tests/__data__/vexml/playback_multi_measure.musicxml create mode 100644 tests/__data__/vexml/playback_multi_staff.musicxml create mode 100644 tests/__data__/vexml/playback_multi_system.musicxml create mode 100644 tests/__data__/vexml/playback_repeat_endings.musicxml create mode 100644 tests/__data__/vexml/playback_repeated_note.musicxml create mode 100644 tests/__data__/vexml/playback_simple.musicxml create mode 100644 tests/__data__/vexml/playback_simple_repeat.musicxml diff --git a/tests/__data__/vexml/playback_multi_measure.musicxml b/tests/__data__/vexml/playback_multi_measure.musicxml new file mode 100644 index 000000000..f977343a3 --- /dev/null +++ b/tests/__data__/vexml/playback_multi_measure.musicxml @@ -0,0 +1,139 @@ + + + + + Untitled score + + + Composer / arranger + + MuseScore 4.5.0 + 2025-03-17 + + + + + + + + + + Flute + Fl. + + Flute + wind.flutes.flute + + + + 1 + 74 + 78.7402 + 0 + + + + + + + 1 + + 0 + + + + G + 2 + + + + + F + 4 + + 1 + 1 + quarter + up + + + + A + 4 + + 1 + 1 + quarter + up + + + + C + 5 + + 1 + 1 + quarter + down + + + + E + 5 + + 1 + 1 + quarter + down + + + + + + F + 5 + + 1 + 1 + quarter + down + + + + D + 5 + + 1 + 1 + quarter + down + + + + B + 4 + + 1 + 1 + quarter + down + + + + G + 4 + + 1 + 1 + quarter + up + + + light-heavy + + + + diff --git a/tests/__data__/vexml/playback_multi_staff.musicxml b/tests/__data__/vexml/playback_multi_staff.musicxml new file mode 100644 index 000000000..4ff05430d --- /dev/null +++ b/tests/__data__/vexml/playback_multi_staff.musicxml @@ -0,0 +1,205 @@ + + + + + Untitled score + + + Composer / arranger + + MuseScore 4.5.0 + 2025-03-17 + + + + + + + + + + Piano + Pno. + + Piano + keyboard.piano + + + + 1 + 1 + 78.7402 + 0 + + + + + + + 2 + + 0 + + + 2 + + G + 2 + + + F + 4 + + + + + F + 4 + + 2 + 1 + quarter + up + 1 + + + + A + 4 + + 2 + 1 + quarter + up + 1 + + + + C + 5 + + 2 + 1 + quarter + down + 1 + + + + E + 5 + + 2 + 1 + quarter + down + 1 + + + 8 + + + + A + 2 + + 1 + 5 + eighth + up + 2 + begin + + + + B + 2 + + 1 + 5 + eighth + up + 2 + continue + + + + C + 3 + + 1 + 5 + eighth + up + 2 + continue + + + + D + 3 + + 1 + 5 + eighth + up + 2 + end + + + + E + 3 + + 1 + 5 + eighth + down + 2 + begin + + + + F + 3 + + 1 + 5 + eighth + down + 2 + continue + + + + G + 3 + + 1 + 5 + eighth + down + 2 + continue + + + + A + 3 + + 1 + 5 + eighth + down + 2 + end + + + light-heavy + + + + diff --git a/tests/__data__/vexml/playback_multi_system.musicxml b/tests/__data__/vexml/playback_multi_system.musicxml new file mode 100644 index 000000000..172ffc537 --- /dev/null +++ b/tests/__data__/vexml/playback_multi_system.musicxml @@ -0,0 +1,154 @@ + + + + + Untitled score + + + Composer / arranger + + MuseScore 4.5.0 + 2025-03-17 + + + + + + + + + + Flute + Fl. + + Flute + wind.flutes.flute + + + + 1 + 74 + 78.7402 + 0 + + + + + + + 1 + + 0 + + + + G + 2 + + + + + E + 4 + + 4 + 1 + whole + + + + + + F + 4 + + 4 + 1 + whole + + + + + + G + 4 + + 4 + 1 + whole + + + + + + A + 4 + + 4 + 1 + whole + + + + + + B + 4 + + 4 + 1 + whole + + + + + + C + 5 + + 4 + 1 + whole + + + + + + D + 5 + + 4 + 1 + whole + + + + + + E + 5 + + 4 + 1 + whole + + + + + + F + 5 + + 4 + 1 + whole + + + light-heavy + + + + diff --git a/tests/__data__/vexml/playback_repeat_endings.musicxml b/tests/__data__/vexml/playback_repeat_endings.musicxml new file mode 100644 index 000000000..073072211 --- /dev/null +++ b/tests/__data__/vexml/playback_repeat_endings.musicxml @@ -0,0 +1,237 @@ + + + + + Untitled score + + + Composer / arranger + + MuseScore 4.5.0 + 2025-03-17 + + + + + + + + + + Flute + Fl. + + Flute + wind.flutes.flute + + + + 1 + 74 + 78.7402 + 0 + + + + + + + 1 + + 0 + + + + G + 2 + + + + + F + 4 + + 1 + 1 + quarter + up + + + + A + 4 + + 1 + 1 + quarter + up + + + + C + 5 + + 1 + 1 + quarter + down + + + + E + 5 + + 1 + 1 + quarter + down + + + + + 1. + + + + E + 5 + + 1 + 1 + quarter + down + + + + C + 5 + + 1 + 1 + quarter + down + + + + A + 4 + + 1 + 1 + quarter + up + + + + F + 4 + + 1 + 1 + quarter + up + + + light-heavy + + + + + + + 2. + + + + G + 4 + + 1 + 1 + quarter + up + + + + B + 4 + + 1 + 1 + quarter + down + + + + D + 5 + + 1 + 1 + quarter + down + + + + F + 5 + + 1 + 1 + quarter + down + + + + + + + + + F + 5 + + 1 + 1 + quarter + down + + + + D + 5 + + 1 + 1 + quarter + down + + + + B + 4 + + 1 + 1 + quarter + down + + + + G + 4 + + 1 + 1 + quarter + up + + + light-heavy + + + + diff --git a/tests/__data__/vexml/playback_repeated_note.musicxml b/tests/__data__/vexml/playback_repeated_note.musicxml new file mode 100644 index 000000000..6c45f06e4 --- /dev/null +++ b/tests/__data__/vexml/playback_repeated_note.musicxml @@ -0,0 +1,97 @@ + + + + + Untitled score + + + Composer / arranger + + MuseScore 4.5.0 + 2025-03-17 + + + + + + + + + + Flute + Fl. + + Flute + wind.flutes.flute + + + + 1 + 74 + 78.7402 + 0 + + + + + + + 1 + + 0 + + + + G + 2 + + + + + C + 5 + + 1 + 1 + quarter + down + + + + C + 5 + + 1 + 1 + quarter + down + + + + C + 5 + + 1 + 1 + quarter + down + + + + C + 5 + + 1 + 1 + quarter + down + + + light-heavy + + + + diff --git a/tests/__data__/vexml/playback_simple.musicxml b/tests/__data__/vexml/playback_simple.musicxml new file mode 100644 index 000000000..dca8b2c49 --- /dev/null +++ b/tests/__data__/vexml/playback_simple.musicxml @@ -0,0 +1,97 @@ + + + + + Untitled score + + + Composer / arranger + + MuseScore 4.5.0 + 2025-03-17 + + + + + + + + + + Flute + Fl. + + Flute + wind.flutes.flute + + + + 1 + 74 + 78.7402 + 0 + + + + + + + 1 + + 0 + + + + G + 2 + + + + + F + 4 + + 1 + 1 + quarter + up + + + + A + 4 + + 1 + 1 + quarter + up + + + + C + 5 + + 1 + 1 + quarter + down + + + + E + 5 + + 1 + 1 + quarter + down + + + light-heavy + + + + diff --git a/tests/__data__/vexml/playback_simple_repeat.musicxml b/tests/__data__/vexml/playback_simple_repeat.musicxml new file mode 100644 index 000000000..428ad5f7e --- /dev/null +++ b/tests/__data__/vexml/playback_simple_repeat.musicxml @@ -0,0 +1,98 @@ + + + + + Untitled score + + + Composer / arranger + + MuseScore 4.5.0 + 2025-03-17 + + + + + + + + + + Flute + Fl. + + Flute + wind.flutes.flute + + + + 1 + 74 + 78.7402 + 0 + + + + + + + 1 + + 0 + + + + G + 2 + + + + + F + 4 + + 1 + 1 + quarter + up + + + + A + 4 + + 1 + 1 + quarter + up + + + + C + 5 + + 1 + 1 + quarter + down + + + + E + 5 + + 1 + 1 + quarter + down + + + light-heavy + + + + + From 4a2ba19389a659a791c266424e23bff85f2176ed Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Mon, 17 Mar 2025 07:04:48 -0400 Subject: [PATCH 03/38] rename SequenceFactory LegacySequenceFactory --- src/elements/score.ts | 2 +- src/playback/index.ts | 2 +- src/playback/{sequencefactory.ts => legacysequencefactory.ts} | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename src/playback/{sequencefactory.ts => legacysequencefactory.ts} (99%) diff --git a/src/elements/score.ts b/src/elements/score.ts index 4a417e53d..6f1d6b805 100644 --- a/src/elements/score.ts +++ b/src/elements/score.ts @@ -348,7 +348,7 @@ export class Score { @util.memoize() private getSequences(): playback.Sequence[] { - const sequences = new playback.SequenceFactory(this.log, this).create(); + const sequences = new playback.LegacySequenceFactory(this.log, this).create(); return sequences; } diff --git a/src/playback/index.ts b/src/playback/index.ts index ccc264559..977d6adbb 100644 --- a/src/playback/index.ts +++ b/src/playback/index.ts @@ -1,6 +1,6 @@ export * from './duration'; export * from './timestamplocator'; export * from './sequence'; -export * from './sequencefactory'; +export * from './legacysequencefactory'; export * from './types'; export * from './cursor'; diff --git a/src/playback/sequencefactory.ts b/src/playback/legacysequencefactory.ts similarity index 99% rename from src/playback/sequencefactory.ts rename to src/playback/legacysequencefactory.ts index ae800bf49..dd703fa13 100644 --- a/src/playback/sequencefactory.ts +++ b/src/playback/legacysequencefactory.ts @@ -16,7 +16,7 @@ type SequenceEvent = { element: PlaybackElement; }; -export class SequenceFactory { +export class LegacySequenceFactory { constructor(private log: Logger, private score: elements.Score) {} create(): Sequence[] { From 600f8522652b28f649ee9bd89feda9bd8ee3661e Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Mon, 17 Mar 2025 07:06:46 -0400 Subject: [PATCH 04/38] create SequenceFactory --- src/playback/sequencefactory.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/playback/sequencefactory.ts diff --git a/src/playback/sequencefactory.ts b/src/playback/sequencefactory.ts new file mode 100644 index 000000000..0d95fd40d --- /dev/null +++ b/src/playback/sequencefactory.ts @@ -0,0 +1,11 @@ +import * as elements from '@/elements'; +import { Logger } from '@/debug'; +import { Sequence } from './sequence'; + +export class SequenceFactory { + constructor(private log: Logger, private score: elements.Score) {} + + create(): Sequence[] { + return []; + } +} From c511990d960125cb6809532abb39420c38e95e90 Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Mon, 17 Mar 2025 07:31:23 -0400 Subject: [PATCH 05/38] add SequenceFactory test --- tests/unit/playback/sequencefactory.test.ts | 25 +++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 tests/unit/playback/sequencefactory.test.ts diff --git a/tests/unit/playback/sequencefactory.test.ts b/tests/unit/playback/sequencefactory.test.ts new file mode 100644 index 000000000..c490ead15 --- /dev/null +++ b/tests/unit/playback/sequencefactory.test.ts @@ -0,0 +1,25 @@ +import * as vexml from '@/index'; +import * as path from 'path'; +import fs from 'fs'; +import { SequenceFactory } from '@/playback/sequencefactory'; +import { NoopLogger } from '@/debug'; + +const DATA_DIR = path.resolve(__dirname, '..', '..', '__data__', 'vexml'); + +// TODO: Create a mechanism for asserting the SequenceFactory output. + +describe(SequenceFactory, () => { + it('creates the correct sequence for a simple example', () => { + expect(() => createSequences('playback_simple.musicxml')).not.toThrow(); + }); +}); + +function createSequences(filename: string) { + const musicXMLPath = path.resolve(DATA_DIR, filename); + const musicXML = fs.readFileSync(musicXMLPath).toString(); + const div = document.createElement('div'); + const score = vexml.renderMusicXML(musicXML, div); + const logger = new NoopLogger(); + const sequenceFactory = new SequenceFactory(logger, score); + return sequenceFactory.create(); +} From ab9e4f622e053d5a2431f5640a434be698b7a104 Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Tue, 18 Mar 2025 08:03:16 -0400 Subject: [PATCH 06/38] update test suite and impl something that pacifies the tests --- src/playback/sequencefactory.ts | 3 +- .../vexml/playback_multi_part.musicxml | 229 ++++++++++++++++++ ...musicxml => playback_multi_stave.musicxml} | 0 ...peat.musicxml => playback_repeat.musicxml} | 0 ...e.musicxml => playback_same_note.musicxml} | 0 tests/unit/playback/sequencefactory.test.ts | 87 ++++++- 6 files changed, 309 insertions(+), 10 deletions(-) create mode 100644 tests/__data__/vexml/playback_multi_part.musicxml rename tests/__data__/vexml/{playback_multi_staff.musicxml => playback_multi_stave.musicxml} (100%) rename tests/__data__/vexml/{playback_simple_repeat.musicxml => playback_repeat.musicxml} (100%) rename tests/__data__/vexml/{playback_repeated_note.musicxml => playback_same_note.musicxml} (100%) diff --git a/src/playback/sequencefactory.ts b/src/playback/sequencefactory.ts index 0d95fd40d..8f75f87ba 100644 --- a/src/playback/sequencefactory.ts +++ b/src/playback/sequencefactory.ts @@ -6,6 +6,7 @@ export class SequenceFactory { constructor(private log: Logger, private score: elements.Score) {} create(): Sequence[] { - return []; + // TODO: Use real impl. + return Array.from({ length: this.score.getPartCount() }, (_, i) => new Sequence(i, [])); } } diff --git a/tests/__data__/vexml/playback_multi_part.musicxml b/tests/__data__/vexml/playback_multi_part.musicxml new file mode 100644 index 000000000..954f56221 --- /dev/null +++ b/tests/__data__/vexml/playback_multi_part.musicxml @@ -0,0 +1,229 @@ + + + + + Untitled score + + + Composer / arranger + + MuseScore 4.5.0 + 2025-03-18 + + + + + + + + + + Flute + Fl. + + Flute + wind.flutes.flute + + + + 1 + 74 + 78.7402 + 0 + + + + Piano + Pno. + + Piano + keyboard.piano + + + + 2 + 1 + 78.7402 + 0 + + + + + + + 1 + + 0 + + + + G + 2 + + + + + F + 4 + + 1 + 1 + quarter + up + + + + A + 4 + + 1 + 1 + quarter + up + + + + C + 5 + + 1 + 1 + quarter + down + + + + E + 5 + + 1 + 1 + quarter + down + + + light-heavy + + + + + + + 1 + + 0 + + + 2 + + G + 2 + + + F + 4 + + + + + F + 4 + + 1 + 1 + quarter + up + 1 + + + + A + 4 + + 1 + 1 + quarter + up + 1 + + + + C + 5 + + 1 + 1 + quarter + down + 1 + + + + E + 5 + + 1 + 1 + quarter + down + 1 + + + 4 + + + + A + 2 + + 1 + 5 + quarter + up + 2 + + + + C + 3 + + 1 + 5 + quarter + up + 2 + + + + E + 3 + + 1 + 5 + quarter + down + 2 + + + + G + 3 + + 1 + 5 + quarter + down + 2 + + + light-heavy + + + + diff --git a/tests/__data__/vexml/playback_multi_staff.musicxml b/tests/__data__/vexml/playback_multi_stave.musicxml similarity index 100% rename from tests/__data__/vexml/playback_multi_staff.musicxml rename to tests/__data__/vexml/playback_multi_stave.musicxml diff --git a/tests/__data__/vexml/playback_simple_repeat.musicxml b/tests/__data__/vexml/playback_repeat.musicxml similarity index 100% rename from tests/__data__/vexml/playback_simple_repeat.musicxml rename to tests/__data__/vexml/playback_repeat.musicxml diff --git a/tests/__data__/vexml/playback_repeated_note.musicxml b/tests/__data__/vexml/playback_same_note.musicxml similarity index 100% rename from tests/__data__/vexml/playback_repeated_note.musicxml rename to tests/__data__/vexml/playback_same_note.musicxml diff --git a/tests/unit/playback/sequencefactory.test.ts b/tests/unit/playback/sequencefactory.test.ts index c490ead15..3cc819f9e 100644 --- a/tests/unit/playback/sequencefactory.test.ts +++ b/tests/unit/playback/sequencefactory.test.ts @@ -6,20 +6,89 @@ import { NoopLogger } from '@/debug'; const DATA_DIR = path.resolve(__dirname, '..', '..', '__data__', 'vexml'); -// TODO: Create a mechanism for asserting the SequenceFactory output. - describe(SequenceFactory, () => { - it('creates the correct sequence for a simple example', () => { - expect(() => createSequences('playback_simple.musicxml')).not.toThrow(); + const logger = new NoopLogger(); + + it('creates for: single measure, single stave, different notes', () => { + const score = render('playback_simple.musicxml'); + const factory = new SequenceFactory(logger, score); + + const sequences = factory.create(); + + expect(sequences).toHaveLength(1); + }); + + it('creates for: single measure, single stave, same notes', () => { + const score = render('playback_same_note.musicxml'); + const factory = new SequenceFactory(logger, score); + + const sequences = factory.create(); + + expect(sequences).toHaveLength(1); + }); + + it('creates for: single measure, multiple staves, different notes', () => { + const score = render('playback_multi_stave.musicxml'); + const factory = new SequenceFactory(logger, score); + + const sequences = factory.create(); + + expect(sequences).toHaveLength(1); + }); + + it('creates for: single measure, multiple staves, multiple parts', () => { + const score = render('playback_multi_part.musicxml'); + const factory = new SequenceFactory(logger, score); + + const sequences = factory.create(); + + expect(sequences).toHaveLength(2); + }); + + it('creates for: multiple measures, single stave, different notes', () => { + const score = render('playback_multi_measure.musicxml'); + const factory = new SequenceFactory(logger, score); + + const sequences = factory.create(); + + expect(sequences).toHaveLength(1); + }); + + it('creates for: single measure, single stave, repeat', () => { + const score = render('playback_repeat.musicxml'); + const factory = new SequenceFactory(logger, score); + + const sequences = factory.create(); + + expect(sequences).toHaveLength(1); + }); + + it('creates for: multiple measures, single stave, repeat with endings', () => { + const score = render('playback_repeat_endings.musicxml'); + const factory = new SequenceFactory(logger, score); + + const sequences = factory.create(); + + expect(sequences).toHaveLength(1); + }); + + it('creates for: multiple measures, single stave, multiple systems', () => { + const score = render('playback_multi_system.musicxml'); + const factory = new SequenceFactory(logger, score); + + const sequences = factory.create(); + + expect(sequences).toHaveLength(1); }); }); -function createSequences(filename: string) { +function render(filename: string) { const musicXMLPath = path.resolve(DATA_DIR, filename); const musicXML = fs.readFileSync(musicXMLPath).toString(); const div = document.createElement('div'); - const score = vexml.renderMusicXML(musicXML, div); - const logger = new NoopLogger(); - const sequenceFactory = new SequenceFactory(logger, score); - return sequenceFactory.create(); + return vexml.renderMusicXML(musicXML, div, { + config: { + WIDTH: 900, + }, + }); } From 35fe1e7f3c93ce47e0e3efefe5cc128433dc9a0a Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Mon, 24 Mar 2025 06:34:34 -0400 Subject: [PATCH 07/38] rename sequence to LegacySequence --- src/elements/score.ts | 2 +- src/playback/cheaplocator.ts | 4 ++-- src/playback/cursor.ts | 8 ++++---- src/playback/expensivelocator.ts | 4 ++-- src/playback/index.ts | 2 +- src/playback/{sequence.ts => legacysequence.ts} | 10 +++++----- src/playback/legacysequencefactory.ts | 16 ++++++++-------- src/playback/sequencefactory.ts | 6 +++--- src/playback/timestamplocator.ts | 10 +++++----- src/playback/types.ts | 2 +- 10 files changed, 32 insertions(+), 32 deletions(-) rename src/playback/{sequence.ts => legacysequence.ts} (57%) diff --git a/src/elements/score.ts b/src/elements/score.ts index 6f1d6b805..4e924b2cc 100644 --- a/src/elements/score.ts +++ b/src/elements/score.ts @@ -347,7 +347,7 @@ export class Score { } @util.memoize() - private getSequences(): playback.Sequence[] { + private getSequences(): playback.LegacySequence[] { const sequences = new playback.LegacySequenceFactory(this.log, this).create(); return sequences; } diff --git a/src/playback/cheaplocator.ts b/src/playback/cheaplocator.ts index ee2fc5067..aa0f742e2 100644 --- a/src/playback/cheaplocator.ts +++ b/src/playback/cheaplocator.ts @@ -1,10 +1,10 @@ import * as playback from '@/playback'; export class CheapLocator { - private sequence: playback.Sequence; + private sequence: playback.LegacySequence; private index: number = 0; - constructor(sequence: playback.Sequence) { + constructor(sequence: playback.LegacySequence) { this.sequence = sequence; } diff --git a/src/playback/cursor.ts b/src/playback/cursor.ts index abadbd0cb..714bbc18a 100644 --- a/src/playback/cursor.ts +++ b/src/playback/cursor.ts @@ -17,7 +17,7 @@ type CursorState = { hasNext: boolean; hasPrevious: boolean; cursorRect: Rect; - sequenceEntry: playback.SequenceEntry; + sequenceEntry: playback.LegacySequenceEntry; }; type EventMap = { @@ -32,7 +32,7 @@ export type CursorVerticalSpan = { export class Cursor { private scroller: Scroller; private states: CursorState[]; - private sequence: playback.Sequence; + private sequence: playback.LegacySequence; private cheapLocator: CheapLocator; private expensiveLocator: ExpensiveLocator; private span: CursorVerticalSpan; @@ -44,7 +44,7 @@ export class Cursor { private constructor(opts: { scroller: Scroller; states: CursorState[]; - sequence: playback.Sequence; + sequence: playback.LegacySequence; cheapLocator: CheapLocator; expensiveLocator: ExpensiveLocator; span: CursorVerticalSpan; @@ -60,7 +60,7 @@ export class Cursor { static create( scrollContainer: HTMLElement, score: elements.Score, - sequence: playback.Sequence, + sequence: playback.LegacySequence, span: CursorVerticalSpan ): Cursor { // NumberRange objects indexed by system index for the part. diff --git a/src/playback/expensivelocator.ts b/src/playback/expensivelocator.ts index 823f1738d..ed0dc7df1 100644 --- a/src/playback/expensivelocator.ts +++ b/src/playback/expensivelocator.ts @@ -2,9 +2,9 @@ import * as playback from '@/playback'; import * as util from '@/util'; export class ExpensiveLocator { - private sequence: playback.Sequence; + private sequence: playback.LegacySequence; - constructor(sequence: playback.Sequence) { + constructor(sequence: playback.LegacySequence) { this.sequence = sequence; } diff --git a/src/playback/index.ts b/src/playback/index.ts index 977d6adbb..fa0b48ed4 100644 --- a/src/playback/index.ts +++ b/src/playback/index.ts @@ -1,6 +1,6 @@ export * from './duration'; export * from './timestamplocator'; -export * from './sequence'; +export * from './legacysequence'; export * from './legacysequencefactory'; export * from './types'; export * from './cursor'; diff --git a/src/playback/sequence.ts b/src/playback/legacysequence.ts similarity index 57% rename from src/playback/sequence.ts rename to src/playback/legacysequence.ts index 155d4ed10..90afb488a 100644 --- a/src/playback/sequence.ts +++ b/src/playback/legacysequence.ts @@ -1,18 +1,18 @@ import { Duration } from './duration'; -import { SequenceEntry } from './types'; +import { LegacySequenceEntry } from './types'; -export class Sequence { - constructor(private partIndex: number, private entries: SequenceEntry[]) {} +export class LegacySequence { + constructor(private partIndex: number, private entries: LegacySequenceEntry[]) {} getPartIndex(): number { return this.partIndex; } - getEntry(index: number): SequenceEntry | null { + getEntry(index: number): LegacySequenceEntry | null { return this.entries.at(index) ?? null; } - getEntries(): SequenceEntry[] { + getEntries(): LegacySequenceEntry[] { return this.entries; } diff --git a/src/playback/legacysequencefactory.ts b/src/playback/legacysequencefactory.ts index dd703fa13..21f1ff162 100644 --- a/src/playback/legacysequencefactory.ts +++ b/src/playback/legacysequencefactory.ts @@ -2,8 +2,8 @@ import * as elements from '@/elements'; import * as util from '@/util'; import { NumberRange } from '@/util'; import { Duration } from './duration'; -import { Sequence } from './sequence'; -import { PlaybackElement, SequenceEntry } from './types'; +import { LegacySequence } from './legacysequence'; +import { PlaybackElement, LegacySequenceEntry } from './types'; import { DurationRange } from './durationrange'; import { MeasureSequenceIterator } from './measuresequenceiterator'; import { Logger } from '@/debug'; @@ -19,15 +19,15 @@ type SequenceEvent = { export class LegacySequenceFactory { constructor(private log: Logger, private score: elements.Score) {} - create(): Sequence[] { - const sequences = new Array(); + create(): LegacySequence[] { + const sequences = new Array(); const partCount = this.score.getPartCount(); for (let partIndex = 0; partIndex < partCount; partIndex++) { const events = this.getSequenceEvents(partIndex); const entries = this.toSequenceEntries(events); - const sequence = new Sequence(partIndex, entries); + const sequence = new LegacySequence(partIndex, entries); sequences.push(sequence); } @@ -124,7 +124,7 @@ export class LegacySequenceFactory { }); } - private toSequenceEntries(events: SequenceEvent[]): SequenceEntry[] { + private toSequenceEntries(events: SequenceEvent[]): LegacySequenceEntry[] { const measures = this.score.getMeasures(); const builder = new SequenceEntryBuilder(this.log, measures); @@ -145,7 +145,7 @@ type XRangeInstruction = /** SequenceEntryBuilder incrementally transforms SequenceEvents to SequenceEntries. */ class SequenceEntryBuilder { - private entries = new Array(); + private entries = new Array(); private anchor: PlaybackElement | null = null; private active = new Array(); private pending = new Array(); @@ -163,7 +163,7 @@ class SequenceEntryBuilder { } } - build(): SequenceEntry[] { + build(): LegacySequenceEntry[] { util.assert(!this.built, 'SequenceEntryBuilder has already built'); if (this.anchor && this.pending.length > 0) { diff --git a/src/playback/sequencefactory.ts b/src/playback/sequencefactory.ts index 8f75f87ba..ab6eaa79c 100644 --- a/src/playback/sequencefactory.ts +++ b/src/playback/sequencefactory.ts @@ -1,12 +1,12 @@ import * as elements from '@/elements'; import { Logger } from '@/debug'; -import { Sequence } from './sequence'; +import { LegacySequence } from './legacysequence'; export class SequenceFactory { constructor(private log: Logger, private score: elements.Score) {} - create(): Sequence[] { + create(): LegacySequence[] { // TODO: Use real impl. - return Array.from({ length: this.score.getPartCount() }, (_, i) => new Sequence(i, [])); + return Array.from({ length: this.score.getPartCount() }, (_, i) => new LegacySequence(i, [])); } } diff --git a/src/playback/timestamplocator.ts b/src/playback/timestamplocator.ts index 0d7064ecc..82cfb3ab2 100644 --- a/src/playback/timestamplocator.ts +++ b/src/playback/timestamplocator.ts @@ -2,22 +2,22 @@ import * as spatial from '@/spatial'; import * as elements from '@/elements'; import * as util from '@/util'; import { Duration } from './duration'; -import { Sequence } from './sequence'; -import { SequenceEntry } from './types'; +import { LegacySequence } from './legacysequence'; +import { LegacySequenceEntry } from './types'; type System = { yRange: util.NumberRange; - entries: SequenceEntry[]; + entries: LegacySequenceEntry[]; }; export class TimestampLocator { private constructor(private systems: System[]) {} - static create(score: elements.Score, sequences: Sequence[]): TimestampLocator { + static create(score: elements.Score, sequences: LegacySequence[]): TimestampLocator { const systems = score.getSystems().map((system) => { const yRange = new util.NumberRange(system.rect().top(), system.rect().bottom()); - const entries = new Array(); + const entries = new Array(); for (const sequence of sequences) { entries.push( ...sequence.getEntries().filter((entry) => entry.anchorElement.getSystemIndex() === system.getIndex()) diff --git a/src/playback/types.ts b/src/playback/types.ts index 999a6a140..f369f28c9 100644 --- a/src/playback/types.ts +++ b/src/playback/types.ts @@ -2,7 +2,7 @@ import * as elements from '@/elements'; import { NumberRange } from '@/util'; import { DurationRange } from './durationrange'; -export type SequenceEntry = { +export type LegacySequenceEntry = { anchorElement: PlaybackElement; activeElements: PlaybackElement[]; durationRange: DurationRange; From c63d0777391fb36e61f38c0414467381c7b51d5a Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Mon, 24 Mar 2025 08:18:12 -0400 Subject: [PATCH 08/38] fold SequenceFactory into Sequence and extend tests --- src/playback/index.ts | 1 + src/playback/sequence.ts | 32 ++++ src/playback/types.ts | 22 +++ tests/unit/playback/sequence.test.ts | 154 ++++++++++++++++++++ tests/unit/playback/sequencefactory.test.ts | 94 ------------ 5 files changed, 209 insertions(+), 94 deletions(-) create mode 100644 src/playback/sequence.ts create mode 100644 tests/unit/playback/sequence.test.ts delete mode 100644 tests/unit/playback/sequencefactory.test.ts diff --git a/src/playback/index.ts b/src/playback/index.ts index fa0b48ed4..174ead5a8 100644 --- a/src/playback/index.ts +++ b/src/playback/index.ts @@ -1,5 +1,6 @@ export * from './duration'; export * from './timestamplocator'; +export * from './sequence'; export * from './legacysequence'; export * from './legacysequencefactory'; export * from './types'; diff --git a/src/playback/sequence.ts b/src/playback/sequence.ts new file mode 100644 index 000000000..7c997c3cf --- /dev/null +++ b/src/playback/sequence.ts @@ -0,0 +1,32 @@ +import * as elements from '@/elements'; +import { Duration } from './duration'; +import { SequenceEvent } from './types'; +import { Logger } from '@/debug'; + +export class Sequence { + constructor(private partIndex: number, private events: SequenceEvent[]) {} + + static create(logger: Logger, score: elements.Score): Sequence[] { + return [new Sequence(0, [])]; + } + + getPartIndex(): number { + return this.partIndex; + } + + getEvent(index: number): SequenceEvent | null { + return this.events.at(index) ?? null; + } + + getEvents(): SequenceEvent[] { + return this.events; + } + + getCount(): number { + return this.events.length; + } + + getDuration(): Duration { + return this.events.at(-1)?.time ?? Duration.zero(); + } +} diff --git a/src/playback/types.ts b/src/playback/types.ts index f369f28c9..4766b514e 100644 --- a/src/playback/types.ts +++ b/src/playback/types.ts @@ -1,6 +1,7 @@ import * as elements from '@/elements'; import { NumberRange } from '@/util'; import { DurationRange } from './durationrange'; +import { Duration } from './duration'; export type LegacySequenceEntry = { anchorElement: PlaybackElement; @@ -9,4 +10,25 @@ export type LegacySequenceEntry = { xRange: NumberRange; }; +export type SequenceEvent = { + time: Duration; + x: number; + transitions: SequenceTransition[]; +}; + +export type SequenceTransition = { + type: SequenceTransitionType; + element: PlaybackElement; +}; + +/** + * Describes what is changing during a sequence event. + */ +export enum SequenceTransitionType { + /** The element is transitioning from inactive to active. */ + Start = 'start', + /** The element is transitioning from active to inactive. */ + Stop = 'stop', +} + export type PlaybackElement = elements.VoiceEntry | elements.Fragment | elements.Measure; diff --git a/tests/unit/playback/sequence.test.ts b/tests/unit/playback/sequence.test.ts new file mode 100644 index 000000000..4b3ce6896 --- /dev/null +++ b/tests/unit/playback/sequence.test.ts @@ -0,0 +1,154 @@ +import * as vexml from '@/index'; +import * as path from 'path'; +import fs from 'fs'; +import { PlaybackElement, Sequence, SequenceTransition, SequenceTransitionType } from '@/playback'; +import { NoopLogger } from '@/debug'; + +const DATA_DIR = path.resolve(__dirname, '..', '..', '__data__', 'vexml'); + +describe(Sequence, () => { + const logger = new NoopLogger(); + + it('creates for: single measure, single stave, different notes', () => { + const score = render('playback_simple.musicxml'); + const elements = score + .getMeasures() + .flatMap((measure) => measure.getFragments()) + .flatMap((fragment) => fragment.getParts()) + .flatMap((part) => part.getStaves()) + .flatMap((stave) => stave.getVoices()) + .flatMap((voice) => voice.getEntries()); + + const sequences = Sequence.create(logger, score); + const sequence = sequences[0]; + const events = sequence.getEvents(); + + expect(elements).toHaveLength(4); + expect(sequences).toHaveLength(1); + // TODO: Uncomment when we have a proper implementation. + // expect(events).toHaveLength(5); + // expect(events[0]).toIncludeAllTransitions([start(elements[0])]); + // expect(events[1]).toIncludeAllTransitions([stop(elements[0]), start(elements[1])]); + // expect(events[2]).toIncludeAllTransitions([stop(elements[1]), start(elements[2])]); + // expect(events[3]).toIncludeAllTransitions([stop(elements[2]), start(elements[3])]); + // expect(events[4]).toIncludeAllTransitions([stop(elements[3])]); + }); + + it('creates for: single measure, single stave, same notes', () => { + const score = render('playback_same_note.musicxml'); + const elements = score + .getMeasures() + .flatMap((measure) => measure.getFragments()) + .flatMap((fragment) => fragment.getParts()) + .flatMap((part) => part.getStaves()) + .flatMap((stave) => stave.getVoices()) + .flatMap((voice) => voice.getEntries()); + + const sequences = Sequence.create(logger, score); + const sequence = sequences[0]; + const events = sequence.getEvents(); + + expect(elements).toHaveLength(4); + expect(sequences).toHaveLength(1); + // TODO: Uncomment when we have a proper implementation. + // expect(events).toHaveLength(5); + // expect(events[0]).toIncludeAllTransitions([start(elements[0])]); + // expect(events[1]).toIncludeAllTransitions([stop(elements[0]), start(elements[1])]); + // expect(events[2]).toIncludeAllTransitions([stop(elements[1]), start(elements[2])]); + // expect(events[3]).toIncludeAllTransitions([stop(elements[2]), start(elements[3])]); + // expect(events[4]).toIncludeAllTransitions([stop(elements[3])]); + }); + + it('creates for: single measure, multiple staves, different notes', () => { + const score = render('playback_multi_stave.musicxml'); + + const sequences = Sequence.create(logger, score); + + expect(sequences).toHaveLength(1); + }); + + it('creates for: single measure, multiple staves, multiple parts', () => { + const score = render('playback_multi_part.musicxml'); + + const sequences = Sequence.create(logger, score); + + expect(sequences).toHaveLength(1); + }); + + it('creates for: multiple measures, single stave, different notes', () => { + const score = render('playback_multi_measure.musicxml'); + + const sequences = Sequence.create(logger, score); + + expect(sequences).toHaveLength(1); + }); + + it('creates for: single measure, single stave, repeat', () => { + const score = render('playback_repeat.musicxml'); + + const sequences = Sequence.create(logger, score); + + expect(sequences).toHaveLength(1); + }); + + it('creates for: multiple measures, single stave, repeat with endings', () => { + const score = render('playback_repeat_endings.musicxml'); + + const sequences = Sequence.create(logger, score); + + expect(sequences).toHaveLength(1); + }); + + it('creates for: multiple measures, single stave, multiple systems', () => { + const score = render('playback_multi_system.musicxml'); + + const sequences = Sequence.create(logger, score); + + expect(sequences).toHaveLength(1); + }); +}); + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace jest { + interface Matchers { + toIncludeAllTransitions(expected: SequenceTransition[]): R; + } + } +} + +expect.extend({ + toIncludeAllTransitions(received: any, expected: SequenceTransition[]) { + try { + expect(received.transitions).toIncludeAllMembers(expected); + return { + pass: true, + message: () => 'expected objects not to match', + }; + } catch (error) { + return { + pass: false, + message: () => (error instanceof Error ? error.message : 'Object mismatch'), + }; + } + }, +}); + +function render(filename: string) { + const musicXMLPath = path.resolve(DATA_DIR, filename); + const musicXML = fs.readFileSync(musicXMLPath).toString(); + const div = document.createElement('div'); + return vexml.renderMusicXML(musicXML, div, { + config: { + WIDTH: 900, + }, + }); +} + +function start(element: PlaybackElement) { + return { type: SequenceTransitionType.Start, element }; +} + +function stop(element: PlaybackElement) { + return { type: SequenceTransitionType.Stop, element }; +} diff --git a/tests/unit/playback/sequencefactory.test.ts b/tests/unit/playback/sequencefactory.test.ts deleted file mode 100644 index 3cc819f9e..000000000 --- a/tests/unit/playback/sequencefactory.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import * as vexml from '@/index'; -import * as path from 'path'; -import fs from 'fs'; -import { SequenceFactory } from '@/playback/sequencefactory'; -import { NoopLogger } from '@/debug'; - -const DATA_DIR = path.resolve(__dirname, '..', '..', '__data__', 'vexml'); - -describe(SequenceFactory, () => { - const logger = new NoopLogger(); - - it('creates for: single measure, single stave, different notes', () => { - const score = render('playback_simple.musicxml'); - const factory = new SequenceFactory(logger, score); - - const sequences = factory.create(); - - expect(sequences).toHaveLength(1); - }); - - it('creates for: single measure, single stave, same notes', () => { - const score = render('playback_same_note.musicxml'); - const factory = new SequenceFactory(logger, score); - - const sequences = factory.create(); - - expect(sequences).toHaveLength(1); - }); - - it('creates for: single measure, multiple staves, different notes', () => { - const score = render('playback_multi_stave.musicxml'); - const factory = new SequenceFactory(logger, score); - - const sequences = factory.create(); - - expect(sequences).toHaveLength(1); - }); - - it('creates for: single measure, multiple staves, multiple parts', () => { - const score = render('playback_multi_part.musicxml'); - const factory = new SequenceFactory(logger, score); - - const sequences = factory.create(); - - expect(sequences).toHaveLength(2); - }); - - it('creates for: multiple measures, single stave, different notes', () => { - const score = render('playback_multi_measure.musicxml'); - const factory = new SequenceFactory(logger, score); - - const sequences = factory.create(); - - expect(sequences).toHaveLength(1); - }); - - it('creates for: single measure, single stave, repeat', () => { - const score = render('playback_repeat.musicxml'); - const factory = new SequenceFactory(logger, score); - - const sequences = factory.create(); - - expect(sequences).toHaveLength(1); - }); - - it('creates for: multiple measures, single stave, repeat with endings', () => { - const score = render('playback_repeat_endings.musicxml'); - const factory = new SequenceFactory(logger, score); - - const sequences = factory.create(); - - expect(sequences).toHaveLength(1); - }); - - it('creates for: multiple measures, single stave, multiple systems', () => { - const score = render('playback_multi_system.musicxml'); - const factory = new SequenceFactory(logger, score); - - const sequences = factory.create(); - - expect(sequences).toHaveLength(1); - }); -}); - -function render(filename: string) { - const musicXMLPath = path.resolve(DATA_DIR, filename); - const musicXML = fs.readFileSync(musicXMLPath).toString(); - const div = document.createElement('div'); - return vexml.renderMusicXML(musicXML, div, { - config: { - WIDTH: 900, - }, - }); -} From 1d11f540775cbccc496d709bf02f2b6418e35900 Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Mon, 24 Mar 2025 08:19:29 -0400 Subject: [PATCH 09/38] remove custom matcher --- tests/unit/playback/sequence.test.ts | 46 ++++++---------------------- 1 file changed, 10 insertions(+), 36 deletions(-) diff --git a/tests/unit/playback/sequence.test.ts b/tests/unit/playback/sequence.test.ts index 4b3ce6896..5def72a1b 100644 --- a/tests/unit/playback/sequence.test.ts +++ b/tests/unit/playback/sequence.test.ts @@ -27,11 +27,11 @@ describe(Sequence, () => { expect(sequences).toHaveLength(1); // TODO: Uncomment when we have a proper implementation. // expect(events).toHaveLength(5); - // expect(events[0]).toIncludeAllTransitions([start(elements[0])]); - // expect(events[1]).toIncludeAllTransitions([stop(elements[0]), start(elements[1])]); - // expect(events[2]).toIncludeAllTransitions([stop(elements[1]), start(elements[2])]); - // expect(events[3]).toIncludeAllTransitions([stop(elements[2]), start(elements[3])]); - // expect(events[4]).toIncludeAllTransitions([stop(elements[3])]); + // expect(events[0].transitions).toIncludeAllMembers([start(elements[0])]); + // expect(events[1].transitions).toIncludeAllMembers([stop(elements[0]), start(elements[1])]); + // expect(events[2].transitions).toIncludeAllMembers([stop(elements[1]), start(elements[2])]); + // expect(events[3].transitions).toIncludeAllMembers([stop(elements[2]), start(elements[3])]); + // expect(events[4].transitions).toIncludeAllMembers([stop(elements[3])]); }); it('creates for: single measure, single stave, same notes', () => { @@ -52,11 +52,11 @@ describe(Sequence, () => { expect(sequences).toHaveLength(1); // TODO: Uncomment when we have a proper implementation. // expect(events).toHaveLength(5); - // expect(events[0]).toIncludeAllTransitions([start(elements[0])]); - // expect(events[1]).toIncludeAllTransitions([stop(elements[0]), start(elements[1])]); - // expect(events[2]).toIncludeAllTransitions([stop(elements[1]), start(elements[2])]); - // expect(events[3]).toIncludeAllTransitions([stop(elements[2]), start(elements[3])]); - // expect(events[4]).toIncludeAllTransitions([stop(elements[3])]); + // expect(events[0].transitions).toIncludeAllMembers([start(elements[0])]); + // expect(events[1].transitions).toIncludeAllMembers([stop(elements[0]), start(elements[1])]); + // expect(events[2].transitions).toIncludeAllMembers([stop(elements[1]), start(elements[2])]); + // expect(events[3].transitions).toIncludeAllMembers([stop(elements[2]), start(elements[3])]); + // expect(events[4].transitions).toIncludeAllMembers([stop(elements[3])]); }); it('creates for: single measure, multiple staves, different notes', () => { @@ -108,32 +108,6 @@ describe(Sequence, () => { }); }); -declare global { - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace jest { - interface Matchers { - toIncludeAllTransitions(expected: SequenceTransition[]): R; - } - } -} - -expect.extend({ - toIncludeAllTransitions(received: any, expected: SequenceTransition[]) { - try { - expect(received.transitions).toIncludeAllMembers(expected); - return { - pass: true, - message: () => 'expected objects not to match', - }; - } catch (error) { - return { - pass: false, - message: () => (error instanceof Error ? error.message : 'Object mismatch'), - }; - } - }, -}); - function render(filename: string) { const musicXMLPath = path.resolve(DATA_DIR, filename); const musicXML = fs.readFileSync(musicXMLPath).toString(); From ab5b6bfbf4c6d31ce83d2116dceceeb53584efdf Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Sun, 30 Mar 2025 12:34:51 -0400 Subject: [PATCH 10/38] replace sequence with timeline concept --- src/playback/duration.ts | 14 ++ src/playback/index.ts | 2 +- src/playback/sequence.ts | 32 ---- src/playback/sequencefactory.ts | 12 -- src/playback/timeline.ts | 222 +++++++++++++++++++++++++++ src/playback/types.ts | 32 ++-- tests/unit/playback/sequence.test.ts | 128 --------------- tests/unit/playback/timeline.test.ts | 162 +++++++++++++++++++ 8 files changed, 416 insertions(+), 188 deletions(-) delete mode 100644 src/playback/sequence.ts delete mode 100644 src/playback/sequencefactory.ts create mode 100644 src/playback/timeline.ts delete mode 100644 tests/unit/playback/sequence.test.ts create mode 100644 tests/unit/playback/timeline.test.ts diff --git a/src/playback/duration.ts b/src/playback/duration.ts index 2d4e60979..663b014ef 100644 --- a/src/playback/duration.ts +++ b/src/playback/duration.ts @@ -24,6 +24,10 @@ export class Duration { return Duration.ms(durations.reduce((acc, duration) => acc + duration.ms, 0)); } + static max(...durations: Duration[]): Duration { + return Duration.ms(Math.max(...durations.map((duration) => duration.ms))); + } + private readonly _ms: number; private constructor(ms: number) { @@ -54,6 +58,16 @@ export class Duration { return this.ms <= duration.ms; } + compare(duration: Duration): -1 | 0 | 1 { + if (this.isLessThan(duration)) { + return -1; + } + if (this.isGreaterThan(duration)) { + return 1; + } + return 0; + } + get ms() { return this._ms; } diff --git a/src/playback/index.ts b/src/playback/index.ts index 174ead5a8..964dea604 100644 --- a/src/playback/index.ts +++ b/src/playback/index.ts @@ -1,6 +1,6 @@ export * from './duration'; export * from './timestamplocator'; -export * from './sequence'; +export * from './timeline'; export * from './legacysequence'; export * from './legacysequencefactory'; export * from './types'; diff --git a/src/playback/sequence.ts b/src/playback/sequence.ts deleted file mode 100644 index 7c997c3cf..000000000 --- a/src/playback/sequence.ts +++ /dev/null @@ -1,32 +0,0 @@ -import * as elements from '@/elements'; -import { Duration } from './duration'; -import { SequenceEvent } from './types'; -import { Logger } from '@/debug'; - -export class Sequence { - constructor(private partIndex: number, private events: SequenceEvent[]) {} - - static create(logger: Logger, score: elements.Score): Sequence[] { - return [new Sequence(0, [])]; - } - - getPartIndex(): number { - return this.partIndex; - } - - getEvent(index: number): SequenceEvent | null { - return this.events.at(index) ?? null; - } - - getEvents(): SequenceEvent[] { - return this.events; - } - - getCount(): number { - return this.events.length; - } - - getDuration(): Duration { - return this.events.at(-1)?.time ?? Duration.zero(); - } -} diff --git a/src/playback/sequencefactory.ts b/src/playback/sequencefactory.ts deleted file mode 100644 index ab6eaa79c..000000000 --- a/src/playback/sequencefactory.ts +++ /dev/null @@ -1,12 +0,0 @@ -import * as elements from '@/elements'; -import { Logger } from '@/debug'; -import { LegacySequence } from './legacysequence'; - -export class SequenceFactory { - constructor(private log: Logger, private score: elements.Score) {} - - create(): LegacySequence[] { - // TODO: Use real impl. - return Array.from({ length: this.score.getPartCount() }, (_, i) => new LegacySequence(i, [])); - } -} diff --git a/src/playback/timeline.ts b/src/playback/timeline.ts new file mode 100644 index 000000000..0878a5a12 --- /dev/null +++ b/src/playback/timeline.ts @@ -0,0 +1,222 @@ +import { Logger } from '@/debug'; +import { Duration } from './duration'; +import { PlaybackElement, TimelineEvent, TransitionEvent } from './types'; +import * as elements from '@/elements'; +import { MeasureSequenceIterator } from './measuresequenceiterator'; +import * as util from '@/util'; + +export class Timeline { + constructor(private partIndex: number, private events: TimelineEvent[]) {} + + static create(logger: Logger, score: elements.Score): Timeline[] { + const partCount = score.getPartCount(); + const timelines = new Array(partCount); + for (let partIndex = 0; partIndex < partCount; partIndex++) { + timelines[partIndex] = new TimelineFactory(logger, score, partIndex).create(); + } + return timelines; + } + + getPartIndex(): number { + return this.partIndex; + } + + getEvent(index: number): TimelineEvent | null { + return this.events.at(index) ?? null; + } + + getEvents(): TimelineEvent[] { + return this.events; + } + + getCount(): number { + return this.events.length; + } + + getDuration(): Duration { + return this.events.at(-1)?.time ?? Duration.zero(); + } +} + +class TimelineFactory { + private events = new Array(); + private currentMeasureStartTime = Duration.zero(); + private nextMeasureStartTime = Duration.zero(); + + constructor(private logger: Logger, private score: elements.Score, private partIndex: number) {} + + create(): Timeline { + this.events = []; + this.currentMeasureStartTime = Duration.zero(); + + this.populateEvents(); + this.sortEvents(); + this.simplifyEvents(); + + return new Timeline(this.partIndex, this.events); + } + + private getMeasuresInPlaybackOrder(): Array<{ measure: elements.Measure; willJump: boolean }> { + const measures = this.score.getMeasures(); + + const measureIndexes = Array.from( + new MeasureSequenceIterator(measures.map((measure, index) => ({ index, jumps: measure.getJumps() }))) + ); + + const result = new Array<{ measure: elements.Measure; willJump: boolean }>(); + + for (let i: number = 0; i < measureIndexes.length; i++) { + const current = measureIndexes[i]; + const next = measureIndexes.at(i + 1); + const willJump = !!next && next !== current + 1; + const measure = measures[current]; + result.push({ measure, willJump }); + } + + return result; + } + + private proposeNextMeasureStartTime(time: Duration): void { + this.nextMeasureStartTime = Duration.max(this.nextMeasureStartTime, time); + } + + private toDuration(beat: util.Fraction, bpm: number): Duration { + const duration = Duration.minutes(beat.divide(new util.Fraction(bpm)).toDecimal()); + // Round to the nearest 100ms. This is needed to correctly group transitions that should belong together. + const ms = Math.round(duration.ms / 100) * 100; + return Duration.ms(ms); + } + + private populateEvents(): void { + for (const { measure, willJump } of this.getMeasuresInPlaybackOrder()) { + if (measure.isMultiMeasure()) { + this.populateMultiMeasureEvents(measure); + } else { + this.populateFragmentEvents(measure); + } + + this.currentMeasureStartTime = this.nextMeasureStartTime; + + if (willJump) { + this.addJumpEvent(this.currentMeasureStartTime); + } + + if (measure.isLastMeasureInSystem()) { + this.addSystemEndEvent(this.currentMeasureStartTime); + } + } + } + + private populateMultiMeasureEvents(measure: elements.Measure): void { + util.assert(measure.isMultiMeasure(), 'measure must be a multi-measure'); + + const bpm = measure.getBpm(); + const duration = this.toDuration(measure.getBeatCount(), bpm); + const startTime = this.currentMeasureStartTime; + const stopTime = startTime.add(duration); + + this.addTransitionStartEvent(startTime, measure); + this.addTransitionStopEvent(stopTime, measure); + + this.proposeNextMeasureStartTime(stopTime); + } + + private populateFragmentEvents(measure: elements.Measure): void { + for (const fragment of measure.getFragments()) { + if (fragment.isNonMusicalGap()) { + this.populateNonMusicalGapEvents(fragment); + } else { + this.populateVoiceEntryEvents(fragment); + } + } + } + + private populateNonMusicalGapEvents(fragment: elements.Fragment): void { + const duration = Duration.ms(fragment.getNonMusicalDurationMs()); + const startTime = this.currentMeasureStartTime; + const stopTime = startTime.add(duration); + + this.addTransitionStartEvent(startTime, fragment); + this.addTransitionStopEvent(stopTime, fragment); + + this.proposeNextMeasureStartTime(stopTime); + } + + private populateVoiceEntryEvents(fragment: elements.Fragment): void { + const voiceEntries = fragment + .getParts() + .filter((part) => part.getIndex() === this.partIndex) + .flatMap((fragmentPart) => fragmentPart.getStaves()) + .flatMap((stave) => stave.getVoices()) + .flatMap((voice) => voice.getEntries()); + + const bpm = fragment.getBpm(); + + for (const voiceEntry of voiceEntries) { + const duration = this.toDuration(voiceEntry.getBeatCount(), bpm); + // NOTE: getStartMeasureBeat() is relative to the start of the measure. + const startTime = this.currentMeasureStartTime.add(this.toDuration(voiceEntry.getStartMeasureBeat(), bpm)); + const stopTime = startTime.add(duration); + + this.addTransitionStartEvent(startTime, voiceEntry); + this.addTransitionStopEvent(stopTime, voiceEntry); + + this.proposeNextMeasureStartTime(stopTime); + } + } + + private sortEvents(): void { + this.events.sort((a, b) => { + if (a.type === b.type) { + return a.time.compare(b.time); + } + const typeOrder = { transition: 0, jump: 1, systemend: 2 }; + return typeOrder[a.type] - typeOrder[b.type]; + }); + } + + private simplifyEvents(): void { + const merged = new Array(); + + const transitions = new Map(); + + for (const event of this.events) { + if (event.type === 'transition') { + if (transitions.has(event.time.ms)) { + transitions.get(event.time.ms)!.transitions.push(...event.transitions); + } else { + transitions.set(event.time.ms, event); + merged.push(event); + } + } else { + merged.push(event); + } + } + + this.events = merged; + } + + private addTransitionStartEvent(time: Duration, element: PlaybackElement): void { + this.events.push({ + type: 'transition', + time, + transitions: [{ type: 'start', element }], + }); + } + + private addTransitionStopEvent(time: Duration, element: PlaybackElement): void { + this.events.push({ + type: 'transition', + time, + transitions: [{ type: 'stop', element }], + }); + } + + private addJumpEvent(time: Duration): void { + this.events.push({ type: 'jump', time }); + } + + private addSystemEndEvent(time: Duration): void { + this.events.push({ type: 'systemend', time }); + } +} diff --git a/src/playback/types.ts b/src/playback/types.ts index 4766b514e..4145f412a 100644 --- a/src/playback/types.ts +++ b/src/playback/types.ts @@ -10,25 +10,27 @@ export type LegacySequenceEntry = { xRange: NumberRange; }; -export type SequenceEvent = { +export type PlaybackElement = elements.VoiceEntry | elements.Fragment | elements.Measure; + +export type TimelineEvent = TransitionEvent | JumpEvent | SystemEndEvent; + +export type TransitionEvent = { + type: 'transition'; time: Duration; - x: number; - transitions: SequenceTransition[]; + transitions: ElementTransition[]; }; -export type SequenceTransition = { - type: SequenceTransitionType; +export type ElementTransition = { + type: 'start' | 'stop'; element: PlaybackElement; }; -/** - * Describes what is changing during a sequence event. - */ -export enum SequenceTransitionType { - /** The element is transitioning from inactive to active. */ - Start = 'start', - /** The element is transitioning from active to inactive. */ - Stop = 'stop', -} +export type JumpEvent = { + type: 'jump'; + time: Duration; +}; -export type PlaybackElement = elements.VoiceEntry | elements.Fragment | elements.Measure; +export type SystemEndEvent = { + type: 'systemend'; + time: Duration; +}; diff --git a/tests/unit/playback/sequence.test.ts b/tests/unit/playback/sequence.test.ts deleted file mode 100644 index 5def72a1b..000000000 --- a/tests/unit/playback/sequence.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import * as vexml from '@/index'; -import * as path from 'path'; -import fs from 'fs'; -import { PlaybackElement, Sequence, SequenceTransition, SequenceTransitionType } from '@/playback'; -import { NoopLogger } from '@/debug'; - -const DATA_DIR = path.resolve(__dirname, '..', '..', '__data__', 'vexml'); - -describe(Sequence, () => { - const logger = new NoopLogger(); - - it('creates for: single measure, single stave, different notes', () => { - const score = render('playback_simple.musicxml'); - const elements = score - .getMeasures() - .flatMap((measure) => measure.getFragments()) - .flatMap((fragment) => fragment.getParts()) - .flatMap((part) => part.getStaves()) - .flatMap((stave) => stave.getVoices()) - .flatMap((voice) => voice.getEntries()); - - const sequences = Sequence.create(logger, score); - const sequence = sequences[0]; - const events = sequence.getEvents(); - - expect(elements).toHaveLength(4); - expect(sequences).toHaveLength(1); - // TODO: Uncomment when we have a proper implementation. - // expect(events).toHaveLength(5); - // expect(events[0].transitions).toIncludeAllMembers([start(elements[0])]); - // expect(events[1].transitions).toIncludeAllMembers([stop(elements[0]), start(elements[1])]); - // expect(events[2].transitions).toIncludeAllMembers([stop(elements[1]), start(elements[2])]); - // expect(events[3].transitions).toIncludeAllMembers([stop(elements[2]), start(elements[3])]); - // expect(events[4].transitions).toIncludeAllMembers([stop(elements[3])]); - }); - - it('creates for: single measure, single stave, same notes', () => { - const score = render('playback_same_note.musicxml'); - const elements = score - .getMeasures() - .flatMap((measure) => measure.getFragments()) - .flatMap((fragment) => fragment.getParts()) - .flatMap((part) => part.getStaves()) - .flatMap((stave) => stave.getVoices()) - .flatMap((voice) => voice.getEntries()); - - const sequences = Sequence.create(logger, score); - const sequence = sequences[0]; - const events = sequence.getEvents(); - - expect(elements).toHaveLength(4); - expect(sequences).toHaveLength(1); - // TODO: Uncomment when we have a proper implementation. - // expect(events).toHaveLength(5); - // expect(events[0].transitions).toIncludeAllMembers([start(elements[0])]); - // expect(events[1].transitions).toIncludeAllMembers([stop(elements[0]), start(elements[1])]); - // expect(events[2].transitions).toIncludeAllMembers([stop(elements[1]), start(elements[2])]); - // expect(events[3].transitions).toIncludeAllMembers([stop(elements[2]), start(elements[3])]); - // expect(events[4].transitions).toIncludeAllMembers([stop(elements[3])]); - }); - - it('creates for: single measure, multiple staves, different notes', () => { - const score = render('playback_multi_stave.musicxml'); - - const sequences = Sequence.create(logger, score); - - expect(sequences).toHaveLength(1); - }); - - it('creates for: single measure, multiple staves, multiple parts', () => { - const score = render('playback_multi_part.musicxml'); - - const sequences = Sequence.create(logger, score); - - expect(sequences).toHaveLength(1); - }); - - it('creates for: multiple measures, single stave, different notes', () => { - const score = render('playback_multi_measure.musicxml'); - - const sequences = Sequence.create(logger, score); - - expect(sequences).toHaveLength(1); - }); - - it('creates for: single measure, single stave, repeat', () => { - const score = render('playback_repeat.musicxml'); - - const sequences = Sequence.create(logger, score); - - expect(sequences).toHaveLength(1); - }); - - it('creates for: multiple measures, single stave, repeat with endings', () => { - const score = render('playback_repeat_endings.musicxml'); - - const sequences = Sequence.create(logger, score); - - expect(sequences).toHaveLength(1); - }); - - it('creates for: multiple measures, single stave, multiple systems', () => { - const score = render('playback_multi_system.musicxml'); - - const sequences = Sequence.create(logger, score); - - expect(sequences).toHaveLength(1); - }); -}); - -function render(filename: string) { - const musicXMLPath = path.resolve(DATA_DIR, filename); - const musicXML = fs.readFileSync(musicXMLPath).toString(); - const div = document.createElement('div'); - return vexml.renderMusicXML(musicXML, div, { - config: { - WIDTH: 900, - }, - }); -} - -function start(element: PlaybackElement) { - return { type: SequenceTransitionType.Start, element }; -} - -function stop(element: PlaybackElement) { - return { type: SequenceTransitionType.Stop, element }; -} diff --git a/tests/unit/playback/timeline.test.ts b/tests/unit/playback/timeline.test.ts new file mode 100644 index 000000000..6c9f2c23a --- /dev/null +++ b/tests/unit/playback/timeline.test.ts @@ -0,0 +1,162 @@ +import * as vexml from '@/index'; +import * as path from 'path'; +import fs from 'fs'; +import { TransitionEvent, JumpEvent, PlaybackElement, SystemEndEvent, Timeline, TimelineEvent } from '@/playback'; +import { NoopLogger } from '@/debug'; + +const DATA_DIR = path.resolve(__dirname, '..', '..', '__data__', 'vexml'); + +describe(Timeline, () => { + const logger = new NoopLogger(); + + it.only('creates for: single measure, single stave, different notes', () => { + const score = render('playback_simple.musicxml'); + const elements = score + .getMeasures() + .flatMap((measure) => measure.getFragments()) + .flatMap((fragment) => fragment.getParts()) + .flatMap((part) => part.getStaves()) + .flatMap((stave) => stave.getVoices()) + .flatMap((voice) => voice.getEntries()); + const describe = Describer.from(elements).describe; + + const timelines = Timeline.create(logger, score); + const timeline = timelines[0]; + const events = timeline.getEvents(); + + expect(elements).toHaveLength(4); + expect(timelines).toHaveLength(1); + expect(describe(events[0])).toBe('[0ms] transition -> start(0)'); + expect(describe(events[1])).toBe('[600ms] transition -> stop(0), start(1)'); + expect(describe(events[2])).toBe('[1200ms] transition -> stop(1), start(2)'); + expect(describe(events[3])).toBe('[1800ms] transition -> stop(2), start(3)'); + expect(describe(events[4])).toBe('[2400ms] transition -> stop(3)'); + expect(describe(events[5])).toBe('[2400ms] systemend'); + expect(describe(events[6])).toBe('undefined'); + }); + + it('creates for: single measure, single stave, same notes', () => { + const score = render('playback_same_note.musicxml'); + const elements = score + .getMeasures() + .flatMap((measure) => measure.getFragments()) + .flatMap((fragment) => fragment.getParts()) + .flatMap((part) => part.getStaves()) + .flatMap((stave) => stave.getVoices()) + .flatMap((voice) => voice.getEntries()); + + const timelines = Timeline.create(logger, score); + const timeline = timelines[0]; + const events = timeline.getEvents(); + + expect(elements).toHaveLength(4); + expect(timelines).toHaveLength(1); + // TODO: Uncomment when we have a proper implementation. + // expect(events).toHaveLength(5); + // expect(events[0].transitions).toIncludeAllMembers([start(elements[0])]); + // expect(events[1].transitions).toIncludeAllMembers([stop(elements[0]), start(elements[1])]); + // expect(events[2].transitions).toIncludeAllMembers([stop(elements[1]), start(elements[2])]); + // expect(events[3].transitions).toIncludeAllMembers([stop(elements[2]), start(elements[3])]); + // expect(events[4].transitions).toIncludeAllMembers([stop(elements[3])]); + }); + + it('creates for: single measure, multiple staves, different notes', () => { + const score = render('playback_multi_stave.musicxml'); + + const timelines = Timeline.create(logger, score); + + expect(timelines).toHaveLength(1); + }); + + it('creates for: single measure, multiple staves, multiple parts', () => { + const score = render('playback_multi_part.musicxml'); + + const timelines = Timeline.create(logger, score); + + expect(timelines).toHaveLength(2); + }); + + it('creates for: multiple measures, single stave, different notes', () => { + const score = render('playback_multi_measure.musicxml'); + + const timelines = Timeline.create(logger, score); + + expect(timelines).toHaveLength(1); + }); + + it('creates for: single measure, single stave, repeat', () => { + const score = render('playback_repeat.musicxml'); + + const timelines = Timeline.create(logger, score); + + expect(timelines).toHaveLength(1); + }); + + it('creates for: multiple measures, single stave, repeat with endings', () => { + const score = render('playback_repeat_endings.musicxml'); + + const timelines = Timeline.create(logger, score); + + expect(timelines).toHaveLength(1); + }); + + it('creates for: multiple measures, single stave, multiple systems', () => { + const score = render('playback_multi_system.musicxml'); + + const timelines = Timeline.create(logger, score); + + expect(timelines).toHaveLength(1); + }); +}); + +function render(filename: string) { + const musicXMLPath = path.resolve(DATA_DIR, filename); + const musicXML = fs.readFileSync(musicXMLPath).toString(); + const div = document.createElement('div'); + return vexml.renderMusicXML(musicXML, div, { + config: { + WIDTH: 900, + }, + }); +} + +/** + * A helper class to describe playback events. + * + * This is done to make debugging failing tests easier. Otherwise, + */ +class Describer { + private constructor(private elements: Map) {} + + static from(elements: PlaybackElement[]) { + const map = new Map(); + elements.forEach((element, index) => { + map.set(element, index); + }); + return new Describer(map); + } + + describe = (event: TimelineEvent | undefined): string => { + switch (event?.type) { + case 'transition': + return this.describeTransition(event); + case 'jump': + return this.describeJump(event); + case 'systemend': + return this.describeSystemEnd(event); + default: + return 'undefined'; + } + }; + + private describeTransition(event: TransitionEvent): string { + const transitions = event.transitions.map((t) => `${t.type}(${this.elements.get(t.element)})`).join(', '); + return `[${event.time.ms}ms] transition -> ${transitions}`; + } + private describeJump(event: JumpEvent): string { + return `[${event.time.ms}ms] jump`; + } + private describeSystemEnd(event: SystemEndEvent): string { + return `[${event.time.ms}ms] systemend`; + } +} From cedc4492ee13743dccaff1e206148b01f0aa1720 Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Sun, 30 Mar 2025 12:39:35 -0400 Subject: [PATCH 11/38] sort transitions to handle stop before start --- src/playback/timeline.ts | 12 ++++++++++++ tests/unit/playback/timeline.test.ts | 1 - 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/playback/timeline.ts b/src/playback/timeline.ts index 0878a5a12..3ec53c963 100644 --- a/src/playback/timeline.ts +++ b/src/playback/timeline.ts @@ -52,6 +52,7 @@ class TimelineFactory { this.populateEvents(); this.sortEvents(); this.simplifyEvents(); + this.sortTransitions(); return new Timeline(this.partIndex, this.events); } @@ -196,6 +197,17 @@ class TimelineFactory { this.events = merged; } + private sortTransitions(): void { + for (const event of this.events) { + if (event.type === 'transition') { + event.transitions.sort((a, b) => { + const typeOrder = { stop: 0, start: 1 }; + return typeOrder[a.type] - typeOrder[b.type]; + }); + } + } + } + private addTransitionStartEvent(time: Duration, element: PlaybackElement): void { this.events.push({ type: 'transition', diff --git a/tests/unit/playback/timeline.test.ts b/tests/unit/playback/timeline.test.ts index 6c9f2c23a..407e1d31b 100644 --- a/tests/unit/playback/timeline.test.ts +++ b/tests/unit/playback/timeline.test.ts @@ -24,7 +24,6 @@ describe(Timeline, () => { const timeline = timelines[0]; const events = timeline.getEvents(); - expect(elements).toHaveLength(4); expect(timelines).toHaveLength(1); expect(describe(events[0])).toBe('[0ms] transition -> start(0)'); expect(describe(events[1])).toBe('[600ms] transition -> stop(0), start(1)'); From 7173a84b3a40aaff2aa66e15c8e2e108af157359 Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Mon, 31 Mar 2025 08:39:30 -0400 Subject: [PATCH 12/38] tweak timeline implementation and finish unit tests --- src/playback/timeline.ts | 19 +-- tests/unit/playback/timeline.test.ts | 216 +++++++++++++++++++-------- 2 files changed, 165 insertions(+), 70 deletions(-) diff --git a/src/playback/timeline.ts b/src/playback/timeline.ts index 3ec53c963..191cc2599 100644 --- a/src/playback/timeline.ts +++ b/src/playback/timeline.ts @@ -66,10 +66,10 @@ class TimelineFactory { const result = new Array<{ measure: elements.Measure; willJump: boolean }>(); - for (let i: number = 0; i < measureIndexes.length; i++) { + for (let i = 0; i < measureIndexes.length; i++) { const current = measureIndexes[i]; const next = measureIndexes.at(i + 1); - const willJump = !!next && next !== current + 1; + const willJump = typeof next === 'number' && next !== current + 1; const measure = measures[current]; result.push({ measure, willJump }); } @@ -100,9 +100,7 @@ class TimelineFactory { if (willJump) { this.addJumpEvent(this.currentMeasureStartTime); - } - - if (measure.isLastMeasureInSystem()) { + } else if (measure.isLastMeasureInSystem()) { this.addSystemEndEvent(this.currentMeasureStartTime); } } @@ -167,12 +165,15 @@ class TimelineFactory { } private sortEvents(): void { + const maxTime = Duration.max(...this.events.map((event) => event.time)); this.events.sort((a, b) => { - if (a.type === b.type) { - return a.time.compare(b.time); + if (a.time.isEqual(b.time)) { + const typeOrder = a.time.isEqual(maxTime) + ? { jump: 0, transition: 1, systemend: 2 } + : { jump: 0, systemend: 1, transition: 2 }; + return typeOrder[a.type] - typeOrder[b.type]; } - const typeOrder = { transition: 0, jump: 1, systemend: 2 }; - return typeOrder[a.type] - typeOrder[b.type]; + return a.time.compare(b.time); }); } diff --git a/tests/unit/playback/timeline.test.ts b/tests/unit/playback/timeline.test.ts index 407e1d31b..ac83785d0 100644 --- a/tests/unit/playback/timeline.test.ts +++ b/tests/unit/playback/timeline.test.ts @@ -9,54 +9,38 @@ const DATA_DIR = path.resolve(__dirname, '..', '..', '__data__', 'vexml'); describe(Timeline, () => { const logger = new NoopLogger(); - it.only('creates for: single measure, single stave, different notes', () => { + it('creates for: single measure, single stave, different notes', () => { const score = render('playback_simple.musicxml'); - const elements = score - .getMeasures() - .flatMap((measure) => measure.getFragments()) - .flatMap((fragment) => fragment.getParts()) - .flatMap((part) => part.getStaves()) - .flatMap((stave) => stave.getVoices()) - .flatMap((voice) => voice.getEntries()); - const describe = Describer.from(elements).describe; const timelines = Timeline.create(logger, score); - const timeline = timelines[0]; - const events = timeline.getEvents(); expect(timelines).toHaveLength(1); - expect(describe(events[0])).toBe('[0ms] transition -> start(0)'); - expect(describe(events[1])).toBe('[600ms] transition -> stop(0), start(1)'); - expect(describe(events[2])).toBe('[1200ms] transition -> stop(1), start(2)'); - expect(describe(events[3])).toBe('[1800ms] transition -> stop(2), start(3)'); - expect(describe(events[4])).toBe('[2400ms] transition -> stop(3)'); - expect(describe(events[5])).toBe('[2400ms] systemend'); - expect(describe(events[6])).toBe('undefined'); + expect(stringify({ score, partIndex: 0, timeline: timelines[0] })).toEqual([ + // stave0: 0 1 2 3 + '[0ms] transition -> start(0)', + '[600ms] transition -> stop(0), start(1)', + '[1200ms] transition -> stop(1), start(2)', + '[1800ms] transition -> stop(2), start(3)', + '[2400ms] transition -> stop(3)', + '[2400ms] systemend', + ]); }); it('creates for: single measure, single stave, same notes', () => { const score = render('playback_same_note.musicxml'); - const elements = score - .getMeasures() - .flatMap((measure) => measure.getFragments()) - .flatMap((fragment) => fragment.getParts()) - .flatMap((part) => part.getStaves()) - .flatMap((stave) => stave.getVoices()) - .flatMap((voice) => voice.getEntries()); const timelines = Timeline.create(logger, score); - const timeline = timelines[0]; - const events = timeline.getEvents(); - expect(elements).toHaveLength(4); expect(timelines).toHaveLength(1); - // TODO: Uncomment when we have a proper implementation. - // expect(events).toHaveLength(5); - // expect(events[0].transitions).toIncludeAllMembers([start(elements[0])]); - // expect(events[1].transitions).toIncludeAllMembers([stop(elements[0]), start(elements[1])]); - // expect(events[2].transitions).toIncludeAllMembers([stop(elements[1]), start(elements[2])]); - // expect(events[3].transitions).toIncludeAllMembers([stop(elements[2]), start(elements[3])]); - // expect(events[4].transitions).toIncludeAllMembers([stop(elements[3])]); + expect(stringify({ score, partIndex: 0, timeline: timelines[0] })).toEqual([ + // stave0: 0 1 2 3 + '[0ms] transition -> start(0)', + '[600ms] transition -> stop(0), start(1)', + '[1200ms] transition -> stop(1), start(2)', + '[1800ms] transition -> stop(2), start(3)', + '[2400ms] transition -> stop(3)', + '[2400ms] systemend', + ]); }); it('creates for: single measure, multiple staves, different notes', () => { @@ -65,6 +49,20 @@ describe(Timeline, () => { const timelines = Timeline.create(logger, score); expect(timelines).toHaveLength(1); + expect(stringify({ score, partIndex: 0, timeline: timelines[0] })).toEqual([ + // stave0: 0 1 2 3 + // stave1: 4 5 6 7 8 9 10 11 + '[0ms] transition -> start(0), start(4)', + '[300ms] transition -> stop(4), start(5)', + '[600ms] transition -> stop(0), stop(5), start(1), start(6)', + '[900ms] transition -> stop(6), start(7)', + '[1200ms] transition -> stop(1), stop(7), start(2), start(8)', + '[1500ms] transition -> stop(8), start(9)', + '[1800ms] transition -> stop(2), stop(9), start(3), start(10)', + '[2100ms] transition -> stop(10), start(11)', + '[2400ms] transition -> stop(3), stop(11)', + '[2400ms] systemend', + ]); }); it('creates for: single measure, multiple staves, multiple parts', () => { @@ -73,6 +71,25 @@ describe(Timeline, () => { const timelines = Timeline.create(logger, score); expect(timelines).toHaveLength(2); + expect(stringify({ score, partIndex: 0, timeline: timelines[0] })).toEqual([ + // stave0: 0 1 2 3 + '[0ms] transition -> start(0)', + '[600ms] transition -> stop(0), start(1)', + '[1200ms] transition -> stop(1), start(2)', + '[1800ms] transition -> stop(2), start(3)', + '[2400ms] transition -> stop(3)', + '[2400ms] systemend', + ]); + expect(stringify({ score, partIndex: 1, timeline: timelines[1] })).toEqual([ + // stave0: 0 1 2 3 + // stave1: 4 5 6 7 + '[0ms] transition -> start(0), start(4)', + '[600ms] transition -> stop(0), stop(4), start(1), start(5)', + '[1200ms] transition -> stop(1), stop(5), start(2), start(6)', + '[1800ms] transition -> stop(2), stop(6), start(3), start(7)', + '[2400ms] transition -> stop(3), stop(7)', + '[2400ms] systemend', + ]); }); it('creates for: multiple measures, single stave, different notes', () => { @@ -81,6 +98,19 @@ describe(Timeline, () => { const timelines = Timeline.create(logger, score); expect(timelines).toHaveLength(1); + expect(stringify({ score, partIndex: 0, timeline: timelines[0] })).toEqual([ + // stave0: 0 1 2 3 4 | 5 6 7 8 + '[0ms] transition -> start(0)', + '[600ms] transition -> stop(0), start(1)', + '[1200ms] transition -> stop(1), start(2)', + '[1800ms] transition -> stop(2), start(3)', + '[2400ms] transition -> stop(3), start(4)', + '[3000ms] transition -> stop(4), start(5)', + '[3600ms] transition -> stop(5), start(6)', + '[4200ms] transition -> stop(6), start(7)', + '[4800ms] transition -> stop(7)', + '[4800ms] systemend', + ]); }); it('creates for: single measure, single stave, repeat', () => { @@ -89,6 +119,20 @@ describe(Timeline, () => { const timelines = Timeline.create(logger, score); expect(timelines).toHaveLength(1); + expect(stringify({ score, partIndex: 0, timeline: timelines[0] })).toEqual([ + // stave0: 0 1 2 3 :|| + '[0ms] transition -> start(0)', + '[600ms] transition -> stop(0), start(1)', + '[1200ms] transition -> stop(1), start(2)', + '[1800ms] transition -> stop(2), start(3)', + '[2400ms] jump', + '[2400ms] transition -> stop(3), start(0)', + '[3000ms] transition -> stop(0), start(1)', + '[3600ms] transition -> stop(1), start(2)', + '[4200ms] transition -> stop(2), start(3)', + '[4800ms] transition -> stop(3)', + '[4800ms] systemend', + ]); }); it('creates for: multiple measures, single stave, repeat with endings', () => { @@ -97,6 +141,33 @@ describe(Timeline, () => { const timelines = Timeline.create(logger, score); expect(timelines).toHaveLength(1); + expect(stringify({ score, partIndex: 0, timeline: timelines[0] })).toEqual([ + // stave0: 0 1 2 3 | [ending1 -> 4 5 6 7] :|| [ending2 -> 8 9 10 11] | 12 13 14 15 + '[0ms] transition -> start(0)', + '[600ms] transition -> stop(0), start(1)', + '[1200ms] transition -> stop(1), start(2)', + '[1800ms] transition -> stop(2), start(3)', + '[2400ms] transition -> stop(3), start(4)', + '[3000ms] transition -> stop(4), start(5)', + '[3600ms] transition -> stop(5), start(6)', + '[4200ms] transition -> stop(6), start(7)', + '[4800ms] jump', + '[4800ms] transition -> stop(7), start(0)', + '[5400ms] transition -> stop(0), start(1)', + '[6000ms] transition -> stop(1), start(2)', + '[6600ms] transition -> stop(2), start(3)', + '[7200ms] jump', + '[7200ms] transition -> stop(3), start(8)', + '[7800ms] transition -> stop(8), start(9)', + '[8400ms] transition -> stop(9), start(10)', + '[9000ms] transition -> stop(10), start(11)', + '[9600ms] transition -> stop(11), start(12)', + '[10200ms] transition -> stop(12), start(13)', + '[10800ms] transition -> stop(13), start(14)', + '[11400ms] transition -> stop(14), start(15)', + '[12000ms] transition -> stop(15)', + '[12000ms] systemend', + ]); }); it('creates for: multiple measures, single stave, multiple systems', () => { @@ -105,6 +176,22 @@ describe(Timeline, () => { const timelines = Timeline.create(logger, score); expect(timelines).toHaveLength(1); + expect(stringify({ score, partIndex: 0, timeline: timelines[0] })).toEqual([ + // system0, stave0: 0 | 1 | 2 | 3 | 4 | 5 + // system1, stave0: 6 | 7 | 8 + '[0ms] transition -> start(0)', + '[2400ms] transition -> stop(0), start(1)', + '[4800ms] transition -> stop(1), start(2)', + '[7200ms] transition -> stop(2), start(3)', + '[9600ms] transition -> stop(3), start(4)', + '[12000ms] transition -> stop(4), start(5)', + '[14400ms] systemend', + '[14400ms] transition -> stop(5), start(6)', + '[16800ms] transition -> stop(6), start(7)', + '[19200ms] transition -> stop(7), start(8)', + '[21600ms] transition -> stop(8)', + '[21600ms] systemend', + ]); }); }); @@ -119,43 +206,50 @@ function render(filename: string) { }); } -/** - * A helper class to describe playback events. - * - * This is done to make debugging failing tests easier. Otherwise, - */ -class Describer { - private constructor(private elements: Map) {} - - static from(elements: PlaybackElement[]) { - const map = new Map(); - elements.forEach((element, index) => { - map.set(element, index); +function stringify({ + score, + partIndex, + timeline, +}: { + score: vexml.Score; + partIndex: number; + timeline: Timeline; +}): string[] { + 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 Describer(map); - } - describe = (event: TimelineEvent | undefined): string => { - switch (event?.type) { + function describeEvent(event: TimelineEvent): string { + switch (event.type) { case 'transition': - return this.describeTransition(event); + return describeTransition(event); case 'jump': - return this.describeJump(event); + return describeJump(event); case 'systemend': - return this.describeSystemEnd(event); - default: - return 'undefined'; + return describeSystemEnd(event); } - }; + } - private describeTransition(event: TransitionEvent): string { - const transitions = event.transitions.map((t) => `${t.type}(${this.elements.get(t.element)})`).join(', '); + function describeTransition(event: TransitionEvent): string { + const transitions = event.transitions.map((t) => `${t.type}(${elements.get(t.element)})`).join(', '); return `[${event.time.ms}ms] transition -> ${transitions}`; } - private describeJump(event: JumpEvent): string { + + function describeJump(event: JumpEvent): string { return `[${event.time.ms}ms] jump`; } - private describeSystemEnd(event: SystemEndEvent): string { + + function describeSystemEnd(event: SystemEndEvent): string { return `[${event.time.ms}ms] systemend`; } + + return timeline.getEvents().map((event) => describeEvent(event)); } From c227c3f06ca1b5def3eb271c972eeee1161e8e21 Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Mon, 31 Mar 2025 19:06:57 -0400 Subject: [PATCH 13/38] create moment type --- src/playback/timeline.ts | 4 ++-- src/playback/types.ts | 29 ++++++++++++++++++++++++---- tests/unit/playback/timeline.test.ts | 15 ++++++++++---- 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/playback/timeline.ts b/src/playback/timeline.ts index 191cc2599..d95b8adaa 100644 --- a/src/playback/timeline.ts +++ b/src/playback/timeline.ts @@ -1,6 +1,6 @@ import { Logger } from '@/debug'; import { Duration } from './duration'; -import { PlaybackElement, TimelineEvent, TransitionEvent } from './types'; +import { PlaybackElement, TimelineEvent, LegacyTransitionEvent } from './types'; import * as elements from '@/elements'; import { MeasureSequenceIterator } from './measuresequenceiterator'; import * as util from '@/util'; @@ -180,7 +180,7 @@ class TimelineFactory { private simplifyEvents(): void { const merged = new Array(); - const transitions = new Map(); + const transitions = new Map(); for (const event of this.events) { if (event.type === 'transition') { diff --git a/src/playback/types.ts b/src/playback/types.ts index 4145f412a..f4d2e7f2d 100644 --- a/src/playback/types.ts +++ b/src/playback/types.ts @@ -12,9 +12,9 @@ export type LegacySequenceEntry = { export type PlaybackElement = elements.VoiceEntry | elements.Fragment | elements.Measure; -export type TimelineEvent = TransitionEvent | JumpEvent | SystemEndEvent; +export type TimelineEvent = LegacyTransitionEvent | LegacyJumpEvent | LegacySystemEndEvent; -export type TransitionEvent = { +export type LegacyTransitionEvent = { type: 'transition'; time: Duration; transitions: ElementTransition[]; @@ -25,12 +25,33 @@ export type ElementTransition = { element: PlaybackElement; }; -export type JumpEvent = { +export type LegacyJumpEvent = { type: 'jump'; time: Duration; }; -export type SystemEndEvent = { +export type LegacySystemEndEvent = { type: 'systemend'; time: Duration; }; + +export type Moment = { + time: Duration; + events: MomentEvent[]; +}; + +export type MomentEvent = ElementTransitionEvent | JumpEvent | SystemEndEvent; + +export type ElementTransitionEvent = { + type: 'transition'; + kind: 'start' | 'stop'; + element: PlaybackElement; +}; + +export type JumpEvent = { + type: 'jump'; +}; + +export type SystemEndEvent = { + type: 'systemend'; +}; diff --git a/tests/unit/playback/timeline.test.ts b/tests/unit/playback/timeline.test.ts index ac83785d0..a37551d0e 100644 --- a/tests/unit/playback/timeline.test.ts +++ b/tests/unit/playback/timeline.test.ts @@ -1,7 +1,14 @@ import * as vexml from '@/index'; import * as path from 'path'; import fs from 'fs'; -import { TransitionEvent, JumpEvent, PlaybackElement, SystemEndEvent, Timeline, TimelineEvent } from '@/playback'; +import { + LegacyTransitionEvent, + LegacyJumpEvent, + PlaybackElement, + LegacySystemEndEvent, + Timeline, + TimelineEvent, +} from '@/playback'; import { NoopLogger } from '@/debug'; const DATA_DIR = path.resolve(__dirname, '..', '..', '__data__', 'vexml'); @@ -238,16 +245,16 @@ function stringify({ } } - function describeTransition(event: TransitionEvent): string { + function describeTransition(event: LegacyTransitionEvent): string { const transitions = event.transitions.map((t) => `${t.type}(${elements.get(t.element)})`).join(', '); return `[${event.time.ms}ms] transition -> ${transitions}`; } - function describeJump(event: JumpEvent): string { + function describeJump(event: LegacyJumpEvent): string { return `[${event.time.ms}ms] jump`; } - function describeSystemEnd(event: SystemEndEvent): string { + function describeSystemEnd(event: LegacySystemEndEvent): string { return `[${event.time.ms}ms] systemend`; } From ffb09bb3046d2b941fc30cc37cd698ecf6593865 Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Mon, 31 Mar 2025 20:35:47 -0400 Subject: [PATCH 14/38] group clusters of events as moments --- src/playback/timeline.ts | 162 +++++++++++------- src/playback/types.ts | 23 --- tests/unit/playback/timeline.test.ts | 244 ++++++++++----------------- 3 files changed, 187 insertions(+), 242 deletions(-) diff --git a/src/playback/timeline.ts b/src/playback/timeline.ts index d95b8adaa..a0a9f01ae 100644 --- a/src/playback/timeline.ts +++ b/src/playback/timeline.ts @@ -1,12 +1,12 @@ import { Logger } from '@/debug'; import { Duration } from './duration'; -import { PlaybackElement, TimelineEvent, LegacyTransitionEvent } from './types'; +import { PlaybackElement, Moment, MomentEvent, ElementTransitionEvent } from './types'; import * as elements from '@/elements'; import { MeasureSequenceIterator } from './measuresequenceiterator'; import * as util from '@/util'; export class Timeline { - constructor(private partIndex: number, private events: TimelineEvent[]) {} + constructor(private partIndex: number, private moments: Moment[], private describer: TimelineDescriber) {} static create(logger: Logger, score: elements.Score): Timeline[] { const partCount = score.getPartCount(); @@ -21,40 +21,46 @@ export class Timeline { return this.partIndex; } - getEvent(index: number): TimelineEvent | null { - return this.events.at(index) ?? null; + getMoment(index: number): Moment | null { + return this.moments.at(index) ?? null; } - getEvents(): TimelineEvent[] { - return this.events; + getMoments(): Moment[] { + return this.moments; } getCount(): number { - return this.events.length; + return this.moments.length; } getDuration(): Duration { - return this.events.at(-1)?.time ?? Duration.zero(); + return this.moments.at(-1)?.time ?? Duration.zero(); + } + + toHumanReadable(): string[] { + return this.describer.describe(this.moments); } } class TimelineFactory { - private events = new Array(); + // timeMs -> moment + private moments = new Map(); private currentMeasureStartTime = Duration.zero(); private nextMeasureStartTime = Duration.zero(); constructor(private logger: Logger, private score: elements.Score, private partIndex: number) {} create(): Timeline { - this.events = []; + this.moments = new Map(); this.currentMeasureStartTime = Duration.zero(); - this.populateEvents(); - this.sortEvents(); - this.simplifyEvents(); - this.sortTransitions(); + this.populateMoments(); + this.sortEventsWithinMoments(); + + const moments = this.getSortedMoments(); + const describer = TimelineDescriber.create(this.score, this.partIndex); - return new Timeline(this.partIndex, this.events); + return new Timeline(this.partIndex, moments, describer); } private getMeasuresInPlaybackOrder(): Array<{ measure: elements.Measure; willJump: boolean }> { @@ -88,7 +94,7 @@ class TimelineFactory { return Duration.ms(ms); } - private populateEvents(): void { + private populateMoments(): void { for (const { measure, willJump } of this.getMeasuresInPlaybackOrder()) { if (measure.isMultiMeasure()) { this.populateMultiMeasureEvents(measure); @@ -164,72 +170,102 @@ class TimelineFactory { } } - private sortEvents(): void { - const maxTime = Duration.max(...this.events.map((event) => event.time)); - this.events.sort((a, b) => { - if (a.time.isEqual(b.time)) { - const typeOrder = a.time.isEqual(maxTime) - ? { jump: 0, transition: 1, systemend: 2 } - : { jump: 0, systemend: 1, transition: 2 }; + private sortEventsWithinMoments(): void { + for (const moment of this.moments.values()) { + moment.events.sort((a, b) => { + const typeOrder = { + transition: 0, + jump: 1, + systemend: 2, + }; return typeOrder[a.type] - typeOrder[b.type]; - } - return a.time.compare(b.time); - }); - } - - private simplifyEvents(): void { - const merged = new Array(); - - const transitions = new Map(); - - for (const event of this.events) { - if (event.type === 'transition') { - if (transitions.has(event.time.ms)) { - transitions.get(event.time.ms)!.transitions.push(...event.transitions); - } else { - transitions.set(event.time.ms, event); - merged.push(event); - } - } else { - merged.push(event); - } + }); } - - this.events = merged; } - private sortTransitions(): void { - for (const event of this.events) { - if (event.type === 'transition') { - event.transitions.sort((a, b) => { - const typeOrder = { stop: 0, start: 1 }; - return typeOrder[a.type] - typeOrder[b.type]; - }); - } - } + private upsert(time: Duration, event: MomentEvent): Moment { + const moment = this.moments.get(time.ms) ?? { time, events: [] }; + moment.events.push(event); + this.moments.set(time.ms, moment); + return moment; } private addTransitionStartEvent(time: Duration, element: PlaybackElement): void { - this.events.push({ + this.upsert(time, { type: 'transition', - time, - transitions: [{ type: 'start', element }], + kind: 'start', + element, }); } private addTransitionStopEvent(time: Duration, element: PlaybackElement): void { - this.events.push({ + this.upsert(time, { type: 'transition', - time, - transitions: [{ type: 'stop', element }], + kind: 'stop', + element, }); } private addJumpEvent(time: Duration): void { - this.events.push({ type: 'jump', time }); + this.upsert(time, { type: 'jump' }); } private addSystemEndEvent(time: Duration): void { - this.events.push({ type: 'systemend', time }); + this.upsert(time, { type: 'systemend' }); + } + + private getSortedMoments(): Moment[] { + const moments = Array.from(this.moments.values()); + return moments.sort((a, b) => a.time.compare(b.time)); + } +} + +class TimelineDescriber { + private constructor(private elements: Map) {} + + static create(score: elements.Score, partIndex: number): TimelineDescriber { + const elements = new Map(); + score + .getMeasures() + .flatMap((measure) => measure.getFragments()) + .flatMap((fragment) => fragment.getParts().at(partIndex) ?? []) + .flatMap((part) => part.getStaves()) + .flatMap((stave) => stave.getVoices()) + .flatMap((voice) => voice.getEntries()) + .forEach((element, index) => { + elements.set(element, index); + }); + return new TimelineDescriber(elements); + } + + describe(moments: Moment[]): string[] { + return moments.map((moment) => this.describeMoment(moment)); + } + + private describeMoment(moment: Moment): string { + return `[${moment.time.ms}ms] ${moment.events.map((event) => this.describeEvent(event)).join(', ')}`; + } + + private describeEvent(event: MomentEvent): string { + switch (event.type) { + case 'transition': + return this.describeTransition(event); + case 'jump': + return this.describeJump(); + case 'systemend': + return this.describeSystemEnd(); + } + } + + private describeTransition(event: ElementTransitionEvent): string { + return `${event.kind}(${this.elements.get(event.element)})`; + } + + private describeJump(): string { + return 'jump'; + } + + private describeSystemEnd(): string { + return 'systemend'; } } diff --git a/src/playback/types.ts b/src/playback/types.ts index f4d2e7f2d..a75d7f526 100644 --- a/src/playback/types.ts +++ b/src/playback/types.ts @@ -12,29 +12,6 @@ export type LegacySequenceEntry = { export type PlaybackElement = elements.VoiceEntry | elements.Fragment | elements.Measure; -export type TimelineEvent = LegacyTransitionEvent | LegacyJumpEvent | LegacySystemEndEvent; - -export type LegacyTransitionEvent = { - type: 'transition'; - time: Duration; - transitions: ElementTransition[]; -}; - -export type ElementTransition = { - type: 'start' | 'stop'; - element: PlaybackElement; -}; - -export type LegacyJumpEvent = { - type: 'jump'; - time: Duration; -}; - -export type LegacySystemEndEvent = { - type: 'systemend'; - time: Duration; -}; - export type Moment = { time: Duration; events: MomentEvent[]; diff --git a/tests/unit/playback/timeline.test.ts b/tests/unit/playback/timeline.test.ts index a37551d0e..3a62561f9 100644 --- a/tests/unit/playback/timeline.test.ts +++ b/tests/unit/playback/timeline.test.ts @@ -1,14 +1,7 @@ import * as vexml from '@/index'; import * as path from 'path'; import fs from 'fs'; -import { - LegacyTransitionEvent, - LegacyJumpEvent, - PlaybackElement, - LegacySystemEndEvent, - Timeline, - TimelineEvent, -} from '@/playback'; +import { Timeline } from '@/playback'; import { NoopLogger } from '@/debug'; const DATA_DIR = path.resolve(__dirname, '..', '..', '__data__', 'vexml'); @@ -22,14 +15,13 @@ describe(Timeline, () => { const timelines = Timeline.create(logger, score); expect(timelines).toHaveLength(1); - expect(stringify({ score, partIndex: 0, timeline: timelines[0] })).toEqual([ + expect(timelines[0].toHumanReadable()).toEqual([ // stave0: 0 1 2 3 - '[0ms] transition -> start(0)', - '[600ms] transition -> stop(0), start(1)', - '[1200ms] transition -> stop(1), start(2)', - '[1800ms] transition -> stop(2), start(3)', - '[2400ms] transition -> stop(3)', - '[2400ms] systemend', + '[0ms] start(0)', + '[600ms] stop(0), start(1)', + '[1200ms] stop(1), start(2)', + '[1800ms] stop(2), start(3)', + '[2400ms] stop(3), systemend', ]); }); @@ -39,14 +31,13 @@ describe(Timeline, () => { const timelines = Timeline.create(logger, score); expect(timelines).toHaveLength(1); - expect(stringify({ score, partIndex: 0, timeline: timelines[0] })).toEqual([ + expect(timelines[0].toHumanReadable()).toEqual([ // stave0: 0 1 2 3 - '[0ms] transition -> start(0)', - '[600ms] transition -> stop(0), start(1)', - '[1200ms] transition -> stop(1), start(2)', - '[1800ms] transition -> stop(2), start(3)', - '[2400ms] transition -> stop(3)', - '[2400ms] systemend', + '[0ms] start(0)', + '[600ms] stop(0), start(1)', + '[1200ms] stop(1), start(2)', + '[1800ms] stop(2), start(3)', + '[2400ms] stop(3), systemend', ]); }); @@ -56,19 +47,18 @@ describe(Timeline, () => { const timelines = Timeline.create(logger, score); expect(timelines).toHaveLength(1); - expect(stringify({ score, partIndex: 0, timeline: timelines[0] })).toEqual([ + expect(timelines[0].toHumanReadable()).toEqual([ // stave0: 0 1 2 3 // stave1: 4 5 6 7 8 9 10 11 - '[0ms] transition -> start(0), start(4)', - '[300ms] transition -> stop(4), start(5)', - '[600ms] transition -> stop(0), stop(5), start(1), start(6)', - '[900ms] transition -> stop(6), start(7)', - '[1200ms] transition -> stop(1), stop(7), start(2), start(8)', - '[1500ms] transition -> stop(8), start(9)', - '[1800ms] transition -> stop(2), stop(9), start(3), start(10)', - '[2100ms] transition -> stop(10), start(11)', - '[2400ms] transition -> stop(3), stop(11)', - '[2400ms] systemend', + '[0ms] start(0), start(4)', + '[300ms] stop(4), start(5)', + '[600ms] stop(0), start(1), stop(5), start(6)', + '[900ms] stop(6), start(7)', + '[1200ms] stop(1), start(2), stop(7), start(8)', + '[1500ms] stop(8), start(9)', + '[1800ms] stop(2), start(3), stop(9), start(10)', + '[2100ms] stop(10), start(11)', + '[2400ms] stop(3), stop(11), systemend', ]); }); @@ -78,24 +68,22 @@ describe(Timeline, () => { const timelines = Timeline.create(logger, score); expect(timelines).toHaveLength(2); - expect(stringify({ score, partIndex: 0, timeline: timelines[0] })).toEqual([ + expect(timelines[0].toHumanReadable()).toEqual([ // stave0: 0 1 2 3 - '[0ms] transition -> start(0)', - '[600ms] transition -> stop(0), start(1)', - '[1200ms] transition -> stop(1), start(2)', - '[1800ms] transition -> stop(2), start(3)', - '[2400ms] transition -> stop(3)', - '[2400ms] systemend', + '[0ms] start(0)', + '[600ms] stop(0), start(1)', + '[1200ms] stop(1), start(2)', + '[1800ms] stop(2), start(3)', + '[2400ms] stop(3), systemend', ]); - expect(stringify({ score, partIndex: 1, timeline: timelines[1] })).toEqual([ + expect(timelines[1].toHumanReadable()).toEqual([ // stave0: 0 1 2 3 // stave1: 4 5 6 7 - '[0ms] transition -> start(0), start(4)', - '[600ms] transition -> stop(0), stop(4), start(1), start(5)', - '[1200ms] transition -> stop(1), stop(5), start(2), start(6)', - '[1800ms] transition -> stop(2), stop(6), start(3), start(7)', - '[2400ms] transition -> stop(3), stop(7)', - '[2400ms] systemend', + '[0ms] start(0), start(4)', + '[600ms] stop(0), start(1), stop(4), start(5)', + '[1200ms] stop(1), start(2), stop(5), start(6)', + '[1800ms] stop(2), start(3), stop(6), start(7)', + '[2400ms] stop(3), stop(7), systemend', ]); }); @@ -105,18 +93,17 @@ describe(Timeline, () => { const timelines = Timeline.create(logger, score); expect(timelines).toHaveLength(1); - expect(stringify({ score, partIndex: 0, timeline: timelines[0] })).toEqual([ + expect(timelines[0].toHumanReadable()).toEqual([ // stave0: 0 1 2 3 4 | 5 6 7 8 - '[0ms] transition -> start(0)', - '[600ms] transition -> stop(0), start(1)', - '[1200ms] transition -> stop(1), start(2)', - '[1800ms] transition -> stop(2), start(3)', - '[2400ms] transition -> stop(3), start(4)', - '[3000ms] transition -> stop(4), start(5)', - '[3600ms] transition -> stop(5), start(6)', - '[4200ms] transition -> stop(6), start(7)', - '[4800ms] transition -> stop(7)', - '[4800ms] systemend', + '[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', ]); }); @@ -126,19 +113,17 @@ describe(Timeline, () => { const timelines = Timeline.create(logger, score); expect(timelines).toHaveLength(1); - expect(stringify({ score, partIndex: 0, timeline: timelines[0] })).toEqual([ + expect(timelines[0].toHumanReadable()).toEqual([ // stave0: 0 1 2 3 :|| - '[0ms] transition -> start(0)', - '[600ms] transition -> stop(0), start(1)', - '[1200ms] transition -> stop(1), start(2)', - '[1800ms] transition -> stop(2), start(3)', - '[2400ms] jump', - '[2400ms] transition -> stop(3), start(0)', - '[3000ms] transition -> stop(0), start(1)', - '[3600ms] transition -> stop(1), start(2)', - '[4200ms] transition -> stop(2), start(3)', - '[4800ms] transition -> stop(3)', - '[4800ms] systemend', + '[0ms] start(0)', + '[600ms] stop(0), start(1)', + '[1200ms] stop(1), start(2)', + '[1800ms] stop(2), start(3)', + '[2400ms] stop(3), start(0), jump', + '[3000ms] stop(0), start(1)', + '[3600ms] stop(1), start(2)', + '[4200ms] stop(2), start(3)', + '[4800ms] stop(3), systemend', ]); }); @@ -148,32 +133,29 @@ describe(Timeline, () => { const timelines = Timeline.create(logger, score); expect(timelines).toHaveLength(1); - expect(stringify({ score, partIndex: 0, timeline: timelines[0] })).toEqual([ + expect(timelines[0].toHumanReadable()).toEqual([ // stave0: 0 1 2 3 | [ending1 -> 4 5 6 7] :|| [ending2 -> 8 9 10 11] | 12 13 14 15 - '[0ms] transition -> start(0)', - '[600ms] transition -> stop(0), start(1)', - '[1200ms] transition -> stop(1), start(2)', - '[1800ms] transition -> stop(2), start(3)', - '[2400ms] transition -> stop(3), start(4)', - '[3000ms] transition -> stop(4), start(5)', - '[3600ms] transition -> stop(5), start(6)', - '[4200ms] transition -> stop(6), start(7)', - '[4800ms] jump', - '[4800ms] transition -> stop(7), start(0)', - '[5400ms] transition -> stop(0), start(1)', - '[6000ms] transition -> stop(1), start(2)', - '[6600ms] transition -> stop(2), start(3)', - '[7200ms] jump', - '[7200ms] transition -> stop(3), start(8)', - '[7800ms] transition -> stop(8), start(9)', - '[8400ms] transition -> stop(9), start(10)', - '[9000ms] transition -> stop(10), start(11)', - '[9600ms] transition -> stop(11), start(12)', - '[10200ms] transition -> stop(12), start(13)', - '[10800ms] transition -> stop(13), start(14)', - '[11400ms] transition -> stop(14), start(15)', - '[12000ms] transition -> stop(15)', - '[12000ms] systemend', + '[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), start(0), jump', + '[5400ms] stop(0), start(1)', + '[6000ms] stop(1), start(2)', + '[6600ms] stop(2), start(3)', + '[7200ms] stop(3), start(8), jump', + '[7800ms] stop(8), start(9)', + '[8400ms] stop(9), start(10)', + '[9000ms] stop(10), start(11)', + '[9600ms] stop(11), start(12)', + '[10200ms] stop(12), start(13)', + '[10800ms] stop(13), start(14)', + '[11400ms] stop(14), start(15)', + '[12000ms] stop(15), systemend', ]); }); @@ -183,21 +165,19 @@ describe(Timeline, () => { const timelines = Timeline.create(logger, score); expect(timelines).toHaveLength(1); - expect(stringify({ score, partIndex: 0, timeline: timelines[0] })).toEqual([ + expect(timelines[0].toHumanReadable()).toEqual([ // system0, stave0: 0 | 1 | 2 | 3 | 4 | 5 // system1, stave0: 6 | 7 | 8 - '[0ms] transition -> start(0)', - '[2400ms] transition -> stop(0), start(1)', - '[4800ms] transition -> stop(1), start(2)', - '[7200ms] transition -> stop(2), start(3)', - '[9600ms] transition -> stop(3), start(4)', - '[12000ms] transition -> stop(4), start(5)', - '[14400ms] systemend', - '[14400ms] transition -> stop(5), start(6)', - '[16800ms] transition -> stop(6), start(7)', - '[19200ms] transition -> stop(7), start(8)', - '[21600ms] transition -> stop(8)', - '[21600ms] systemend', + '[0ms] start(0)', + '[2400ms] stop(0), start(1)', + '[4800ms] stop(1), start(2)', + '[7200ms] stop(2), start(3)', + '[9600ms] stop(3), start(4)', + '[12000ms] stop(4), start(5)', + '[14400ms] stop(5), start(6), systemend', + '[16800ms] stop(6), start(7)', + '[19200ms] stop(7), start(8)', + '[21600ms] stop(8), systemend', ]); }); }); @@ -212,51 +192,3 @@ function render(filename: string) { }, }); } - -function stringify({ - score, - partIndex, - timeline, -}: { - score: vexml.Score; - partIndex: number; - timeline: Timeline; -}): string[] { - 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); - }); - - function describeEvent(event: TimelineEvent): string { - switch (event.type) { - case 'transition': - return describeTransition(event); - case 'jump': - return describeJump(event); - case 'systemend': - return describeSystemEnd(event); - } - } - - function describeTransition(event: LegacyTransitionEvent): string { - const transitions = event.transitions.map((t) => `${t.type}(${elements.get(t.element)})`).join(', '); - return `[${event.time.ms}ms] transition -> ${transitions}`; - } - - function describeJump(event: LegacyJumpEvent): string { - return `[${event.time.ms}ms] jump`; - } - - function describeSystemEnd(event: LegacySystemEndEvent): string { - return `[${event.time.ms}ms] systemend`; - } - - return timeline.getEvents().map((event) => describeEvent(event)); -} From 681bcfc9d0cb076dd0d96e0bfec6cd18e0b8bb0f Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Tue, 1 Apr 2025 06:36:31 -0400 Subject: [PATCH 15/38] rename playback.Moment to playback.TimelineMoment --- src/playback/timeline.ts | 22 +++++++++++----------- src/playback/types.ts | 6 +++--- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/playback/timeline.ts b/src/playback/timeline.ts index a0a9f01ae..452886dff 100644 --- a/src/playback/timeline.ts +++ b/src/playback/timeline.ts @@ -1,12 +1,12 @@ import { Logger } from '@/debug'; import { Duration } from './duration'; -import { PlaybackElement, Moment, MomentEvent, ElementTransitionEvent } from './types'; +import { PlaybackElement, TimelineMoment, TimelineMomentEvent, ElementTransitionEvent } from './types'; import * as elements from '@/elements'; import { MeasureSequenceIterator } from './measuresequenceiterator'; import * as util from '@/util'; export class Timeline { - constructor(private partIndex: number, private moments: Moment[], private describer: TimelineDescriber) {} + constructor(private partIndex: number, private moments: TimelineMoment[], private describer: TimelineDescriber) {} static create(logger: Logger, score: elements.Score): Timeline[] { const partCount = score.getPartCount(); @@ -21,11 +21,11 @@ export class Timeline { return this.partIndex; } - getMoment(index: number): Moment | null { + getMoment(index: number): TimelineMoment | null { return this.moments.at(index) ?? null; } - getMoments(): Moment[] { + getMoments(): TimelineMoment[] { return this.moments; } @@ -44,14 +44,14 @@ export class Timeline { class TimelineFactory { // timeMs -> moment - private moments = new Map(); + private moments = new Map(); private currentMeasureStartTime = Duration.zero(); private nextMeasureStartTime = Duration.zero(); constructor(private logger: Logger, private score: elements.Score, private partIndex: number) {} create(): Timeline { - this.moments = new Map(); + this.moments = new Map(); this.currentMeasureStartTime = Duration.zero(); this.populateMoments(); @@ -183,7 +183,7 @@ class TimelineFactory { } } - private upsert(time: Duration, event: MomentEvent): Moment { + private upsert(time: Duration, event: TimelineMomentEvent): TimelineMoment { const moment = this.moments.get(time.ms) ?? { time, events: [] }; moment.events.push(event); this.moments.set(time.ms, moment); @@ -214,7 +214,7 @@ class TimelineFactory { this.upsert(time, { type: 'systemend' }); } - private getSortedMoments(): Moment[] { + private getSortedMoments(): TimelineMoment[] { const moments = Array.from(this.moments.values()); return moments.sort((a, b) => a.time.compare(b.time)); } @@ -238,15 +238,15 @@ class TimelineDescriber { return new TimelineDescriber(elements); } - describe(moments: Moment[]): string[] { + describe(moments: TimelineMoment[]): string[] { return moments.map((moment) => this.describeMoment(moment)); } - private describeMoment(moment: Moment): string { + private describeMoment(moment: TimelineMoment): string { return `[${moment.time.ms}ms] ${moment.events.map((event) => this.describeEvent(event)).join(', ')}`; } - private describeEvent(event: MomentEvent): string { + private describeEvent(event: TimelineMomentEvent): string { switch (event.type) { case 'transition': return this.describeTransition(event); diff --git a/src/playback/types.ts b/src/playback/types.ts index a75d7f526..5f5104345 100644 --- a/src/playback/types.ts +++ b/src/playback/types.ts @@ -12,12 +12,12 @@ export type LegacySequenceEntry = { export type PlaybackElement = elements.VoiceEntry | elements.Fragment | elements.Measure; -export type Moment = { +export type TimelineMoment = { time: Duration; - events: MomentEvent[]; + events: TimelineMomentEvent[]; }; -export type MomentEvent = ElementTransitionEvent | JumpEvent | SystemEndEvent; +export type TimelineMomentEvent = ElementTransitionEvent | JumpEvent | SystemEndEvent; export type ElementTransitionEvent = { type: 'transition'; From 83870ade58565b26b541ece67f4ac29cee4deb4a Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Tue, 1 Apr 2025 08:18:11 -0400 Subject: [PATCH 16/38] create CursorFrame class --- src/elements/note.ts | 14 ++++++++ src/elements/types.ts | 10 ++++++ src/playback/cursorframe.ts | 69 +++++++++++++++++++++++++++++++++++++ src/playback/types.ts | 19 ++++++++++ 4 files changed, 112 insertions(+) create mode 100644 src/playback/cursorframe.ts diff --git a/src/elements/note.ts b/src/elements/note.ts index 889aa5e83..ce7e751a2 100644 --- a/src/elements/note.ts +++ b/src/elements/note.ts @@ -3,6 +3,7 @@ import { Config } from '@/config'; import { Logger } from '@/debug'; import { Rect } from '@/spatial'; import { Fraction } from '@/util'; +import { Pitch } from './types'; export class Note { private constructor( @@ -24,6 +25,19 @@ export class Note { return this.noteRender.rect; } + getPitch(): Pitch { + const note = this.document.getNote(this.noteRender.key); + return { + step: note.pitch.step, + octave: note.pitch.octave, + accidentalCode: note.accidental?.code ?? null, + }; + } + + sharesACurveWith(note: Note): boolean { + return this.noteRender.curveIds.some((curveId) => note.noteRender.curveIds.includes(curveId)); + } + /** Returns the measure beat that this note starts on. */ getStartMeasureBeat(): Fraction { return Fraction.fromFractionLike(this.document.getVoiceEntry(this.noteRender.key).measureBeat); diff --git a/src/elements/types.ts b/src/elements/types.ts index 82ebb6222..75a501a7f 100644 --- a/src/elements/types.ts +++ b/src/elements/types.ts @@ -8,6 +8,7 @@ import { Score } from './score'; import { Stave } from './stave'; import { System } from './system'; import { Voice } from './voice'; +import { Enum, EnumValues } from '@/util'; /** * Represents a rendered musical element. @@ -21,6 +22,15 @@ export type VexmlElement = Score | System | Measure | Fragment | Part | Stave | */ export type VoiceEntry = Note | Rest; +export type AccidentalCode = EnumValues; +export const ACCIDENTAL_CODES = new Enum(['#', '##', 'b', 'bb', 'n', 'd', '_', 'db', '+', '++'] as const); + +export type Pitch = { + step: string; + octave: number; + accidentalCode: AccidentalCode | null; +}; + export type EventMap = { click: ClickEvent; enter: EnterEvent; diff --git a/src/playback/cursorframe.ts b/src/playback/cursorframe.ts new file mode 100644 index 000000000..4a5efefa1 --- /dev/null +++ b/src/playback/cursorframe.ts @@ -0,0 +1,69 @@ +import * as util from '@/util'; +import * as elements from '@/elements'; +import { DurationRange } from './durationrange'; +import { CursorFrameHint, PlaybackElement, RetriggerHint, SustainHint } from './types'; + +export class CursorFrame { + constructor( + public readonly tRange: DurationRange, + public readonly xRange: util.NumberRange, + public readonly yRange: util.NumberRange, + public readonly activeElements: PlaybackElement[] + ) {} + + getHints(previousFrame: CursorFrame): CursorFrameHint[] { + return [...this.getRetriggerHints(previousFrame), ...this.getSustainHints(previousFrame)]; + } + + private getRetriggerHints(previousFrame: CursorFrame): RetriggerHint[] { + const hints = new Array(); + + 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) => + 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(); + + 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) => + isPitchEqual(currentNote.getPitch(), previousNote.getPitch()) + ); + if (previousNote && previousNote.sharesACurveWith(currentNote)) { + hints.push({ + type: 'sustain', + previousElement: previousNote, + currentElement: currentNote, + }); + } + } + + return hints; + } +} + +function isPitchEqual(a: elements.Pitch, b: elements.Pitch): boolean { + return a.step === b.step && a.octave === b.octave && a.accidentalCode === b.accidentalCode; +} diff --git a/src/playback/types.ts b/src/playback/types.ts index 5f5104345..5b2e6fcf1 100644 --- a/src/playback/types.ts +++ b/src/playback/types.ts @@ -32,3 +32,22 @@ export type JumpEvent = { export type SystemEndEvent = { type: 'systemend'; }; + +export type CursorFrame = { + timeRange: DurationRange; + xRange: NumberRange; +}; + +export type CursorFrameHint = RetriggerHint | SustainHint; + +export type RetriggerHint = { + type: 'retrigger'; + untriggerElement: PlaybackElement; + retriggerElement: PlaybackElement; +}; + +export type SustainHint = { + type: 'sustain'; + previousElement: PlaybackElement; + currentElement: PlaybackElement; +}; From b1f23e13a4a94140df4c28fc702d10e37cdd0cd4 Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Wed, 2 Apr 2025 19:49:29 -0400 Subject: [PATCH 17/38] add hints to CursorFrame class --- src/playback/cursorframe.ts | 6 ++++++ src/spatial/rect.ts | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/src/playback/cursorframe.ts b/src/playback/cursorframe.ts index 4a5efefa1..087fae846 100644 --- a/src/playback/cursorframe.ts +++ b/src/playback/cursorframe.ts @@ -17,6 +17,9 @@ export class CursorFrame { 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'); @@ -41,6 +44,9 @@ export class CursorFrame { 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'); diff --git a/src/spatial/rect.ts b/src/spatial/rect.ts index 8067ee00b..cb46b66ae 100644 --- a/src/spatial/rect.ts +++ b/src/spatial/rect.ts @@ -1,5 +1,6 @@ import { Point } from './point'; import { Shape } from './types'; +import * as util from '@/util'; /** Represents a rectangle in a 2D coordinate system. */ export class Rect implements Shape { @@ -22,6 +23,10 @@ export class Rect implements Shape { return new Rect(shape.left(), shape.top(), shape.right() - shape.left(), shape.bottom() - shape.top()); } + static fromRanges({ xRange, yRange }: { xRange: util.NumberRange; yRange: util.NumberRange }): Rect { + return new Rect(xRange.start, yRange.start, xRange.getSize(), yRange.getSize()); + } + static empty() { return new Rect(0, 0, 0, 0); } From c7a6cfc8f299c695c17f45d9b46aacb82e19de60 Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Wed, 2 Apr 2025 19:51:00 -0400 Subject: [PATCH 18/38] rename Cursor to LegacyCursor --- src/elements/score.ts | 6 +++--- src/index.ts | 2 +- src/playback/index.ts | 2 +- src/playback/{cursor.ts => legacycursor.ts} | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) rename src/playback/{cursor.ts => legacycursor.ts} (98%) diff --git a/src/elements/score.ts b/src/elements/score.ts index 4e924b2cc..36537bd03 100644 --- a/src/elements/score.ts +++ b/src/elements/score.ts @@ -17,7 +17,7 @@ import { Measure } from './measure'; /** Score is a rendered musical score. */ export class Score { private isEventsCreated = false; - private cursors = new Array(); + private cursors = new Array(); private constructor( private config: Config, @@ -64,7 +64,7 @@ export class Score { return this.root.getScrollContainer(); } - addCursor(opts?: { partIndex?: number; span?: playback.CursorVerticalSpan }): playback.Cursor { + addCursor(opts?: { partIndex?: number; span?: playback.CursorVerticalSpan }): playback.LegacyCursor { const partCount = this.getPartCount(); const partIndex = opts?.partIndex ?? 0; @@ -78,7 +78,7 @@ export class Score { const sequence = this.getSequences().find((sequence) => sequence.getPartIndex() === partIndex); util.assertDefined(sequence); - const cursor = playback.Cursor.create(this.root.getScrollContainer(), this, sequence, span); + const cursor = playback.LegacyCursor.create(this.root.getScrollContainer(), this, sequence, span); this.cursors.push(cursor); return cursor; } diff --git a/src/index.ts b/src/index.ts index 6980dcca9..d4b0c8681 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,5 +29,5 @@ export { } from './formatting'; export { type SchemaDescriptor, type SchemaType, type SchemaConfig } from './schema'; export { SimpleCursor } from './components'; -export { type Cursor } from './playback'; +export { type LegacyCursor as Cursor } from './playback'; export { type Logger, type LogLevel, ConsoleLogger, MemoryLogger, type MemoryLog, NoopLogger } from './debug'; diff --git a/src/playback/index.ts b/src/playback/index.ts index 964dea604..142e9ac2d 100644 --- a/src/playback/index.ts +++ b/src/playback/index.ts @@ -4,4 +4,4 @@ export * from './timeline'; export * from './legacysequence'; export * from './legacysequencefactory'; export * from './types'; -export * from './cursor'; +export * from './legacycursor'; diff --git a/src/playback/cursor.ts b/src/playback/legacycursor.ts similarity index 98% rename from src/playback/cursor.ts rename to src/playback/legacycursor.ts index 714bbc18a..6424e51ed 100644 --- a/src/playback/cursor.ts +++ b/src/playback/legacycursor.ts @@ -29,7 +29,7 @@ export type CursorVerticalSpan = { toPartIndex: number; }; -export class Cursor { +export class LegacyCursor { private scroller: Scroller; private states: CursorState[]; private sequence: playback.LegacySequence; @@ -62,7 +62,7 @@ export class Cursor { score: elements.Score, sequence: playback.LegacySequence, span: CursorVerticalSpan - ): Cursor { + ): LegacyCursor { // NumberRange objects indexed by system index for the part. const systemPartYRanges = new Array(); @@ -119,7 +119,7 @@ export class Cursor { const cheapLocator = new CheapLocator(sequence); const expensiveLocator = new ExpensiveLocator(sequence); - return new Cursor({ + return new LegacyCursor({ scroller, states, sequence, From 14baeda725733f541001563e2092025ac8ff9385 Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Thu, 3 Apr 2025 06:57:16 -0400 Subject: [PATCH 19/38] move CursorFrame creation to cursorframe.ts --- src/playback/cursor.ts | 25 +++++++++++++++++ src/playback/cursorframe.ts | 53 +++++++++++++++++++++++++++++++++++- src/playback/legacycursor.ts | 11 ++------ src/playback/timeline.ts | 2 +- src/playback/types.ts | 5 ++++ src/spatial/index.ts | 2 +- 6 files changed, 87 insertions(+), 11 deletions(-) create mode 100644 src/playback/cursor.ts diff --git a/src/playback/cursor.ts b/src/playback/cursor.ts new file mode 100644 index 000000000..8cb8ea976 --- /dev/null +++ b/src/playback/cursor.ts @@ -0,0 +1,25 @@ +import { CursorFrame } from './cursorframe'; +import { Scroller } from './scroller'; +import { Timeline } from './timeline'; +import { CursorVerticalSpan } from './types'; +import * as elements from '@/elements'; + +export class Cursor { + private constructor( + private frames: CursorFrame[], + private scroller: Scroller, + private timeline: Timeline, + private span: CursorVerticalSpan + ) {} + + static create( + scrollContainer: HTMLElement, + score: elements.Score, + timeline: Timeline, + span: CursorVerticalSpan + ): Cursor { + const frames = CursorFrame.create(score, timeline, span); + const scroller = new Scroller(scrollContainer); + return new Cursor(frames, scroller, timeline, span); + } +} diff --git a/src/playback/cursorframe.ts b/src/playback/cursorframe.ts index 087fae846..f0092452e 100644 --- a/src/playback/cursorframe.ts +++ b/src/playback/cursorframe.ts @@ -1,7 +1,9 @@ import * as util from '@/util'; import * as elements from '@/elements'; +import * as spatial from '@/spatial'; import { DurationRange } from './durationrange'; -import { CursorFrameHint, PlaybackElement, RetriggerHint, SustainHint } from './types'; +import { CursorFrameHint, CursorVerticalSpan, PlaybackElement, RetriggerHint, SustainHint } from './types'; +import { Timeline } from './timeline'; export class CursorFrame { constructor( @@ -11,6 +13,11 @@ export class CursorFrame { public readonly activeElements: PlaybackElement[] ) {} + static create(score: elements.Score, timeline: Timeline, span: CursorVerticalSpan): CursorFrame[] { + const factory = new CursorFrameFactory(score, timeline, span); + return factory.create(); + } + getHints(previousFrame: CursorFrame): CursorFrameHint[] { return [...this.getRetriggerHints(previousFrame), ...this.getSustainHints(previousFrame)]; } @@ -73,3 +80,47 @@ export class CursorFrame { function isPitchEqual(a: elements.Pitch, b: elements.Pitch): boolean { return a.step === b.step && a.octave === b.octave && a.accidentalCode === b.accidentalCode; } + +class CursorFrameFactory { + constructor(private score: elements.Score, private timeline: Timeline, private span: CursorVerticalSpan) {} + + create(): CursorFrame[] { + // NumberRange objects indexed by system index for the part. + const systemPartYRanges = new Array(); + for (const system of this.score.getSystems()) { + const rect = spatial.Rect.merge( + system + .getMeasures() + .flatMap((measure) => measure.getFragments()) + .flatMap((fragment) => fragment.getParts()) + .filter((part) => this.span.fromPartIndex <= part.getIndex() && part.getIndex() <= this.span.toPartIndex) + .map((part) => part.rect()) + ); + const yRange = new util.NumberRange(rect.top(), rect.bottom()); + systemPartYRanges.push(yRange); + } + + const frames = new Array(); + + const activeElements = new Array(); + + const momentCount = this.timeline.getMomentCount(); + for (let index = 0; index < momentCount - 1; index++) { + const current = this.timeline.getMoment(index); + const next = this.timeline.getMoment(index + 1); + + util.assertNotNull(current); + util.assertNotNull(next); + + const tRange = new DurationRange(current.time, next.time); + // TODO: Decide what the anchor element should be and calculate these. + const xRange = new util.NumberRange(0, 0); + const yRange = systemPartYRanges[0]; + + const frame = new CursorFrame(tRange, xRange, yRange, [...activeElements]); + frames.push(frame); + } + + return frames; + } +} diff --git a/src/playback/legacycursor.ts b/src/playback/legacycursor.ts index 6424e51ed..45af0727c 100644 --- a/src/playback/legacycursor.ts +++ b/src/playback/legacycursor.ts @@ -3,10 +3,10 @@ import * as util from '@/util'; import * as spatial from '@/spatial'; import * as events from '@/events'; import * as elements from '@/elements'; -import { Rect } from '@/spatial'; import { CheapLocator } from './cheaplocator'; import { ExpensiveLocator } from './expensivelocator'; import { Scroller } from './scroller'; +import { CursorVerticalSpan } from './types'; // 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. @@ -16,7 +16,7 @@ type CursorState = { index: number; hasNext: boolean; hasPrevious: boolean; - cursorRect: Rect; + cursorRect: spatial.Rect; sequenceEntry: playback.LegacySequenceEntry; }; @@ -24,11 +24,6 @@ type EventMap = { change: CursorState; }; -export type CursorVerticalSpan = { - fromPartIndex: number; - toPartIndex: number; -}; - export class LegacyCursor { private scroller: Scroller; private states: CursorState[]; @@ -67,7 +62,7 @@ export class LegacyCursor { const systemPartYRanges = new Array(); for (const system of score.getSystems()) { - const rect = Rect.merge( + const rect = spatial.Rect.merge( system .getMeasures() .flatMap((measure) => measure.getFragments()) diff --git a/src/playback/timeline.ts b/src/playback/timeline.ts index 452886dff..73c1cc106 100644 --- a/src/playback/timeline.ts +++ b/src/playback/timeline.ts @@ -29,7 +29,7 @@ export class Timeline { return this.moments; } - getCount(): number { + getMomentCount(): number { return this.moments.length; } diff --git a/src/playback/types.ts b/src/playback/types.ts index 5b2e6fcf1..789a60aae 100644 --- a/src/playback/types.ts +++ b/src/playback/types.ts @@ -51,3 +51,8 @@ export type SustainHint = { previousElement: PlaybackElement; currentElement: PlaybackElement; }; + +export type CursorVerticalSpan = { + fromPartIndex: number; + toPartIndex: number; +}; diff --git a/src/spatial/index.ts b/src/spatial/index.ts index 3717930f2..9dc1c7959 100644 --- a/src/spatial/index.ts +++ b/src/spatial/index.ts @@ -1,5 +1,5 @@ export * from './point'; -export * from './rect'; export * from './circle'; export * from './types'; export * from './quadtree'; +export * from './rect'; From d6432580515d0ce93dd16b4fc1c59e4462ce9e54 Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Thu, 3 Apr 2025 07:09:13 -0400 Subject: [PATCH 20/38] add measure and system data to TimelineMomentEvent types --- src/playback/timeline.ts | 40 ++++++++++++++++++++++------------------ src/playback/types.ts | 3 +++ 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/src/playback/timeline.ts b/src/playback/timeline.ts index 73c1cc106..bb8b32031 100644 --- a/src/playback/timeline.ts +++ b/src/playback/timeline.ts @@ -105,9 +105,11 @@ class TimelineFactory { this.currentMeasureStartTime = this.nextMeasureStartTime; if (willJump) { - this.addJumpEvent(this.currentMeasureStartTime); + this.addJumpEvent(this.currentMeasureStartTime, measure); } else if (measure.isLastMeasureInSystem()) { - this.addSystemEndEvent(this.currentMeasureStartTime); + const system = this.score.getSystems().at(measure.getSystemIndex()); + util.assertDefined(system); + this.addSystemEndEvent(this.currentMeasureStartTime, system); } } } @@ -120,8 +122,8 @@ class TimelineFactory { const startTime = this.currentMeasureStartTime; const stopTime = startTime.add(duration); - this.addTransitionStartEvent(startTime, measure); - this.addTransitionStopEvent(stopTime, measure); + this.addTransitionStartEvent(startTime, measure, measure); + this.addTransitionStopEvent(stopTime, measure, measure); this.proposeNextMeasureStartTime(stopTime); } @@ -129,25 +131,25 @@ class TimelineFactory { private populateFragmentEvents(measure: elements.Measure): void { for (const fragment of measure.getFragments()) { if (fragment.isNonMusicalGap()) { - this.populateNonMusicalGapEvents(fragment); + this.populateNonMusicalGapEvents(fragment, measure); } else { - this.populateVoiceEntryEvents(fragment); + this.populateVoiceEntryEvents(fragment, measure); } } } - private populateNonMusicalGapEvents(fragment: elements.Fragment): void { + private populateNonMusicalGapEvents(fragment: elements.Fragment, measure: elements.Measure): void { const duration = Duration.ms(fragment.getNonMusicalDurationMs()); const startTime = this.currentMeasureStartTime; const stopTime = startTime.add(duration); - this.addTransitionStartEvent(startTime, fragment); - this.addTransitionStopEvent(stopTime, fragment); + this.addTransitionStartEvent(startTime, measure, fragment); + this.addTransitionStopEvent(stopTime, measure, fragment); this.proposeNextMeasureStartTime(stopTime); } - private populateVoiceEntryEvents(fragment: elements.Fragment): void { + private populateVoiceEntryEvents(fragment: elements.Fragment, measure: elements.Measure): void { const voiceEntries = fragment .getParts() .filter((part) => part.getIndex() === this.partIndex) @@ -163,8 +165,8 @@ class TimelineFactory { const startTime = this.currentMeasureStartTime.add(this.toDuration(voiceEntry.getStartMeasureBeat(), bpm)); const stopTime = startTime.add(duration); - this.addTransitionStartEvent(startTime, voiceEntry); - this.addTransitionStopEvent(stopTime, voiceEntry); + this.addTransitionStartEvent(startTime, measure, voiceEntry); + this.addTransitionStopEvent(stopTime, measure, voiceEntry); this.proposeNextMeasureStartTime(stopTime); } @@ -190,28 +192,30 @@ class TimelineFactory { return moment; } - private addTransitionStartEvent(time: Duration, element: PlaybackElement): void { + private addTransitionStartEvent(time: Duration, measure: elements.Measure, element: PlaybackElement): void { this.upsert(time, { type: 'transition', kind: 'start', + measure, element, }); } - private addTransitionStopEvent(time: Duration, element: PlaybackElement): void { + private addTransitionStopEvent(time: Duration, measure: elements.Measure, element: PlaybackElement): void { this.upsert(time, { type: 'transition', kind: 'stop', + measure, element, }); } - private addJumpEvent(time: Duration): void { - this.upsert(time, { type: 'jump' }); + private addJumpEvent(time: Duration, measure: elements.Measure): void { + this.upsert(time, { type: 'jump', measure }); } - private addSystemEndEvent(time: Duration): void { - this.upsert(time, { type: 'systemend' }); + private addSystemEndEvent(time: Duration, system: elements.System): void { + this.upsert(time, { type: 'systemend', system }); } private getSortedMoments(): TimelineMoment[] { diff --git a/src/playback/types.ts b/src/playback/types.ts index 789a60aae..408df797d 100644 --- a/src/playback/types.ts +++ b/src/playback/types.ts @@ -22,15 +22,18 @@ export type TimelineMomentEvent = ElementTransitionEvent | JumpEvent | SystemEnd export type ElementTransitionEvent = { type: 'transition'; kind: 'start' | 'stop'; + measure: elements.Measure; element: PlaybackElement; }; export type JumpEvent = { type: 'jump'; + measure: elements.Measure; }; export type SystemEndEvent = { type: 'systemend'; + system: elements.System; }; export type CursorFrame = { From faafbdf13d793c472a566199b44353439d463bbe Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Thu, 3 Apr 2025 08:01:47 -0400 Subject: [PATCH 21/38] flesh out CursorFrameFactory --- src/playback/cursorframe.ts | 176 +++++++++++++++++++++++++++++------- 1 file changed, 145 insertions(+), 31 deletions(-) diff --git a/src/playback/cursorframe.ts b/src/playback/cursorframe.ts index f0092452e..9e8068d22 100644 --- a/src/playback/cursorframe.ts +++ b/src/playback/cursorframe.ts @@ -2,7 +2,14 @@ import * as util from '@/util'; import * as elements from '@/elements'; import * as spatial from '@/spatial'; import { DurationRange } from './durationrange'; -import { CursorFrameHint, CursorVerticalSpan, PlaybackElement, RetriggerHint, SustainHint } from './types'; +import { + CursorFrameHint, + CursorVerticalSpan, + PlaybackElement, + RetriggerHint, + SustainHint, + TimelineMoment, +} from './types'; import { Timeline } from './timeline'; export class CursorFrame { @@ -35,7 +42,7 @@ export class CursorFrame { // very small. for (const currentNote of currentNotes) { const previousNote = previousNotes.find((previousNote) => - isPitchEqual(currentNote.getPitch(), previousNote.getPitch()) + this.isPitchEqual(currentNote.getPitch(), previousNote.getPitch()) ); if (previousNote && !previousNote.sharesACurveWith(currentNote)) { hints.push({ @@ -62,7 +69,7 @@ export class CursorFrame { // very small. for (const currentNote of currentNotes) { const previousNote = previousNotes.find((previousNote) => - isPitchEqual(currentNote.getPitch(), previousNote.getPitch()) + this.isPitchEqual(currentNote.getPitch(), previousNote.getPitch()) ); if (previousNote && previousNote.sharesACurveWith(currentNote)) { hints.push({ @@ -75,18 +82,146 @@ export class CursorFrame { return hints; } -} -function isPitchEqual(a: elements.Pitch, b: elements.Pitch): boolean { - return a.step === b.step && a.octave === b.octave && a.accidentalCode === b.accidentalCode; + private isPitchEqual(a: elements.Pitch, b: elements.Pitch): boolean { + return a.step === b.step && a.octave === b.octave && a.accidentalCode === b.accidentalCode; + } } +/** + * An element used to spatially scope the cursor frame. + */ +type Anchor = PlaybackElement | elements.System; + class CursorFrameFactory { + private frames = new Array(); + private activeElements = new Set(); + constructor(private score: elements.Score, private timeline: Timeline, private span: CursorVerticalSpan) {} create(): CursorFrame[] { - // NumberRange objects indexed by system index for the part. - const systemPartYRanges = new Array(); + this.frames = []; + this.activeElements = new Set(); + + const anchors = this.timeline.getMoments().map((moment) => this.identifyAnchor(moment)); + + const momentCount = this.timeline.getMomentCount(); + for (let index = 0; index < momentCount - 1; index++) { + const currentMoment = this.timeline.getMoment(index); + const nextMoment = this.timeline.getMoment(index + 1); + util.assertNotNull(currentMoment); + util.assertNotNull(nextMoment); + + const currentAnchor = anchors.at(index); + const nextAnchor = anchors.at(index + 1); + util.assertDefined(currentAnchor); + util.assertDefined(nextAnchor); + + const tRange = new DurationRange(currentMoment.time, nextMoment.time); + const xRange = this.getXRange(currentMoment, currentAnchor, nextAnchor); + const yRange = this.getYRange(currentAnchor); + + this.updateActiveElements(currentMoment); + + this.addFrame(tRange, xRange, yRange); + } + + return this.frames; + } + + private updateActiveElements(moment: TimelineMoment) { + for (const event of moment.events) { + if (event.type === 'transition') { + if (event.kind === 'start') { + this.activeElements.add(event.element); + } else if (event.kind === 'stop') { + this.activeElements.delete(event.element); + } + } + } + } + + private getXRange(currentMoment: TimelineMoment, currentAnchor: Anchor, nextAnchor: Anchor): util.NumberRange { + const left = currentAnchor.rect().left(); + let right = nextAnchor.rect().left(); + + // Check to see if the current moment has any events that should adjust the right boundary. + for (const event of currentMoment.events) { + if (event.type === 'systemend') { + right = event.system.rect().right(); + break; + } else if (event.type === 'jump') { + right = event.measure.rect().right(); + break; + } + } + + return new util.NumberRange(left, right); + } + + private getYRange(currentAnchor: Anchor) { + const systemIndex = this.getSystemIndex(currentAnchor); + const yRange = this.getYRangeBySystemIndex().at(systemIndex); + util.assertDefined(yRange); + return yRange; + } + + private getSystemIndex(anchor: Anchor) { + if (anchor instanceof elements.System) { + return anchor.getIndex(); + } else { + return anchor.getSystemIndex(); + } + } + + /** + * Returns the element that is considered the "main" element of the moment. This is used to determine the x-range of + * a frame. + */ + private identifyAnchor(moment: TimelineMoment): Anchor { + // First, select the start elements. + const elements = moment.events + .filter((e) => e.type === 'transition') + .filter((e) => e.kind === 'start') + .map((e) => e.element); + + // If there are no start elements, use the first measure. + if (elements.length === 0) { + for (const event of moment.events) { + if (event.type === 'transition') { + return event.measure; + } else if (event.type === 'jump') { + return event.measure; + } else if (event.type === 'systemend') { + return event.system; + } else { + util.assertUnreachable(); + } + } + } + + // Otherwise, select the leftmost element. + let anchor = elements[0]; + let min = elements[0].rect().left(); + for (const element of elements) { + const x = element.rect().left(); + if (x < min) { + min = x; + anchor = element; + } + } + return anchor; + } + + private addFrame(tRange: DurationRange, xRange: util.NumberRange, yRange: util.NumberRange): void { + const frame = new CursorFrame(tRange, xRange, yRange, [...this.activeElements]); + this.frames.push(frame); + } + + @util.memoize() + private getYRangeBySystemIndex(): util.NumberRange[] { + const result = new Array(); + for (const system of this.score.getSystems()) { const rect = spatial.Rect.merge( system @@ -97,30 +232,9 @@ class CursorFrameFactory { .map((part) => part.rect()) ); const yRange = new util.NumberRange(rect.top(), rect.bottom()); - systemPartYRanges.push(yRange); - } - - const frames = new Array(); - - const activeElements = new Array(); - - const momentCount = this.timeline.getMomentCount(); - for (let index = 0; index < momentCount - 1; index++) { - const current = this.timeline.getMoment(index); - const next = this.timeline.getMoment(index + 1); - - util.assertNotNull(current); - util.assertNotNull(next); - - const tRange = new DurationRange(current.time, next.time); - // TODO: Decide what the anchor element should be and calculate these. - const xRange = new util.NumberRange(0, 0); - const yRange = systemPartYRanges[0]; - - const frame = new CursorFrame(tRange, xRange, yRange, [...activeElements]); - frames.push(frame); + result.push(yRange); } - return frames; + return result; } } From 13d9f62c50f67684b172868b67d488f6c7af5193 Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Thu, 3 Apr 2025 08:52:27 -0400 Subject: [PATCH 22/38] scaffold cursor frame tests --- src/playback/cursorframe.ts | 105 ++---------------------- src/playback/index.ts | 1 + src/playback/types.ts | 5 -- tests/unit/playback/cursorframe.test.ts | 56 +++++++++++++ 4 files changed, 64 insertions(+), 103 deletions(-) create mode 100644 tests/unit/playback/cursorframe.test.ts diff --git a/src/playback/cursorframe.ts b/src/playback/cursorframe.ts index 9e8068d22..e88f8cbef 100644 --- a/src/playback/cursorframe.ts +++ b/src/playback/cursorframe.ts @@ -88,11 +88,6 @@ export class CursorFrame { } } -/** - * An element used to spatially scope the cursor frame. - */ -type Anchor = PlaybackElement | elements.System; - class CursorFrameFactory { private frames = new Array(); private activeElements = new Set(); @@ -103,27 +98,13 @@ class CursorFrameFactory { this.frames = []; this.activeElements = new Set(); - const anchors = this.timeline.getMoments().map((moment) => this.identifyAnchor(moment)); - - const momentCount = this.timeline.getMomentCount(); - for (let index = 0; index < momentCount - 1; index++) { - const currentMoment = this.timeline.getMoment(index); - const nextMoment = this.timeline.getMoment(index + 1); - util.assertNotNull(currentMoment); - util.assertNotNull(nextMoment); - - const currentAnchor = anchors.at(index); - const nextAnchor = anchors.at(index + 1); - util.assertDefined(currentAnchor); - util.assertDefined(nextAnchor); - - const tRange = new DurationRange(currentMoment.time, nextMoment.time); - const xRange = this.getXRange(currentMoment, currentAnchor, nextAnchor); - const yRange = this.getYRange(currentAnchor); - - this.updateActiveElements(currentMoment); - - this.addFrame(tRange, xRange, yRange); + for (let index = 0; index < this.timeline.getMomentCount() - 1; index++) { + // const [anchor1, anchor2] = this.identifyAnchorPair(momentIndex); + // const tRange = new DurationRange(currentMoment.time, nextMoment.time); + // const xRange = this.getXRange(currentMoment, currentAnchor, nextAnchor); + // const yRange = this.getYRange(currentAnchor); + // this.updateActiveElements(currentMoment); + // this.addFrame(tRange, xRange, yRange); } return this.frames; @@ -141,78 +122,6 @@ class CursorFrameFactory { } } - private getXRange(currentMoment: TimelineMoment, currentAnchor: Anchor, nextAnchor: Anchor): util.NumberRange { - const left = currentAnchor.rect().left(); - let right = nextAnchor.rect().left(); - - // Check to see if the current moment has any events that should adjust the right boundary. - for (const event of currentMoment.events) { - if (event.type === 'systemend') { - right = event.system.rect().right(); - break; - } else if (event.type === 'jump') { - right = event.measure.rect().right(); - break; - } - } - - return new util.NumberRange(left, right); - } - - private getYRange(currentAnchor: Anchor) { - const systemIndex = this.getSystemIndex(currentAnchor); - const yRange = this.getYRangeBySystemIndex().at(systemIndex); - util.assertDefined(yRange); - return yRange; - } - - private getSystemIndex(anchor: Anchor) { - if (anchor instanceof elements.System) { - return anchor.getIndex(); - } else { - return anchor.getSystemIndex(); - } - } - - /** - * Returns the element that is considered the "main" element of the moment. This is used to determine the x-range of - * a frame. - */ - private identifyAnchor(moment: TimelineMoment): Anchor { - // First, select the start elements. - const elements = moment.events - .filter((e) => e.type === 'transition') - .filter((e) => e.kind === 'start') - .map((e) => e.element); - - // If there are no start elements, use the first measure. - if (elements.length === 0) { - for (const event of moment.events) { - if (event.type === 'transition') { - return event.measure; - } else if (event.type === 'jump') { - return event.measure; - } else if (event.type === 'systemend') { - return event.system; - } else { - util.assertUnreachable(); - } - } - } - - // Otherwise, select the leftmost element. - let anchor = elements[0]; - let min = elements[0].rect().left(); - for (const element of elements) { - const x = element.rect().left(); - if (x < min) { - min = x; - anchor = element; - } - } - return anchor; - } - private addFrame(tRange: DurationRange, xRange: util.NumberRange, yRange: util.NumberRange): void { const frame = new CursorFrame(tRange, xRange, yRange, [...this.activeElements]); this.frames.push(frame); diff --git a/src/playback/index.ts b/src/playback/index.ts index 142e9ac2d..404ca4069 100644 --- a/src/playback/index.ts +++ b/src/playback/index.ts @@ -5,3 +5,4 @@ export * from './legacysequence'; export * from './legacysequencefactory'; export * from './types'; export * from './legacycursor'; +export * from './cursorframe'; diff --git a/src/playback/types.ts b/src/playback/types.ts index 408df797d..82dcfcc13 100644 --- a/src/playback/types.ts +++ b/src/playback/types.ts @@ -36,11 +36,6 @@ export type SystemEndEvent = { system: elements.System; }; -export type CursorFrame = { - timeRange: DurationRange; - xRange: NumberRange; -}; - export type CursorFrameHint = RetriggerHint | SustainHint; export type RetriggerHint = { diff --git a/tests/unit/playback/cursorframe.test.ts b/tests/unit/playback/cursorframe.test.ts new file mode 100644 index 000000000..6a23873ee --- /dev/null +++ b/tests/unit/playback/cursorframe.test.ts @@ -0,0 +1,56 @@ +import * as vexml from '@/index'; +import * as path from 'path'; +import fs from 'fs'; +import { CursorFrame, Timeline } from '@/playback'; +import { NoopLogger } from '@/debug'; + +const DATA_DIR = path.resolve(__dirname, '..', '..', '__data__', 'vexml'); + +describe(CursorFrame, () => { + it.only('creates for: single measure, single stave, different notes', () => { + const [score, timelines] = render('playback_simple.musicxml'); + + const frames = CursorFrame.create(score, timelines[0], { fromPartIndex: 0, toPartIndex: 0 }); + }); + + // it('creates for: single measure, single stave, same notes', () => { + // const [score, timelines] = render('playback_same_note.musicxml'); + // }); + + // it('creates for: single measure, multiple staves, different notes', () => { + // const [score, timelines] = render('playback_multi_stave.musicxml'); + // }); + + // it('creates for: single measure, multiple staves, multiple parts', () => { + // const [score, timelines] = render('playback_multi_part.musicxml'); + // }); + + // it('creates for: multiple measures, single stave, different notes', () => { + // const [score, timelines] = render('playback_multi_measure.musicxml'); + // }); + + // it('creates for: single measure, single stave, repeat', () => { + // const [score, timelines] = render('playback_repeat.musicxml'); + // }); + + // it('creates for: multiple measures, single stave, repeat with endings', () => { + // const [score, timelines] = render('playback_repeat_endings.musicxml'); + // }); + + // it('creates for: multiple measures, single stave, multiple systems', () => { + // const [score, timelines] = render('playback_multi_system.musicxml'); + // }); +}); + +function render(filename: string): [vexml.Score, Timeline[]] { + const musicXMLPath = path.resolve(DATA_DIR, filename); + const musicXML = fs.readFileSync(musicXMLPath).toString(); + const div = document.createElement('div'); + const score = vexml.renderMusicXML(musicXML, div, { + config: { + WIDTH: 900, + }, + }); + const timelines = Timeline.create(new NoopLogger(), score); + return [score, timelines]; +} From 3d54a7bbd0c10edf1c51098ec701717bf72c8432 Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Thu, 3 Apr 2025 21:48:15 -0400 Subject: [PATCH 23/38] finish rough cursorframe impl and unit test --- src/elements/part.ts | 5 + src/playback/cursor.ts | 4 +- src/playback/cursorframe.ts | 276 +++++++++++++++++++++--- src/playback/index.ts | 1 + src/playback/timeline.ts | 11 +- src/playback/types.ts | 2 +- tests/unit/playback/cursorframe.test.ts | 38 +++- 7 files changed, 298 insertions(+), 39 deletions(-) diff --git a/src/elements/part.ts b/src/elements/part.ts index b08d1d7a8..e06bda6d2 100644 --- a/src/elements/part.ts +++ b/src/elements/part.ts @@ -37,6 +37,11 @@ export class Part { return this.partRender.key.partIndex; } + /** Returns the system index. */ + getSystemIndex(): number { + return this.partRender.key.systemIndex; + } + /** Returns the start measure beat for the part. */ getStartMeasureBeat(): Fraction { return ( diff --git a/src/playback/cursor.ts b/src/playback/cursor.ts index 8cb8ea976..a75423d0b 100644 --- a/src/playback/cursor.ts +++ b/src/playback/cursor.ts @@ -3,6 +3,7 @@ import { Scroller } from './scroller'; import { Timeline } from './timeline'; import { CursorVerticalSpan } from './types'; import * as elements from '@/elements'; +import { Logger } from '@/debug'; export class Cursor { private constructor( @@ -13,12 +14,13 @@ export class Cursor { ) {} static create( + logger: Logger, scrollContainer: HTMLElement, score: elements.Score, timeline: Timeline, span: CursorVerticalSpan ): Cursor { - const frames = CursorFrame.create(score, timeline, span); + const frames = CursorFrame.create(logger, score, timeline, span); const scroller = new Scroller(scrollContainer); return new Cursor(frames, scroller, timeline, span); } diff --git a/src/playback/cursorframe.ts b/src/playback/cursorframe.ts index e88f8cbef..9c1ce1eab 100644 --- a/src/playback/cursorframe.ts +++ b/src/playback/cursorframe.ts @@ -1,6 +1,7 @@ import * as util from '@/util'; import * as elements from '@/elements'; import * as spatial from '@/spatial'; +import { Logger } from '@/debug'; import { DurationRange } from './durationrange'; import { CursorFrameHint, @@ -12,23 +13,79 @@ import { } from './types'; import { Timeline } from './timeline'; +type TRangeSource = { + moment: TimelineMoment; +}; + +type XRangeSource = + | { type: 'system'; system: elements.System; bound: 'left' | 'right' } + | { type: 'measure'; measure: elements.Measure; bound: 'left' | 'right' } + | { type: 'element'; element: PlaybackElement; bound: 'left' | 'right' }; + +type YRangeSource = { + part: elements.Part; + bound: 'top' | 'bottom'; +}; + export class CursorFrame { constructor( - public readonly tRange: DurationRange, - public readonly xRange: util.NumberRange, - public readonly yRange: util.NumberRange, - public readonly activeElements: PlaybackElement[] + private tRangeSources: [TRangeSource, TRangeSource], + private xRangeSources: [XRangeSource, XRangeSource], + private yRangeSources: [YRangeSource, YRangeSource], + private activeElements: PlaybackElement[], + private describer: CursorFrameDescriber ) {} - static create(score: elements.Score, timeline: Timeline, span: CursorVerticalSpan): CursorFrame[] { - const factory = new CursorFrameFactory(score, timeline, span); + static create(logger: Logger, score: elements.Score, timeline: Timeline, span: CursorVerticalSpan): CursorFrame[] { + const factory = new CursorFrameFactory(logger, score, timeline, span); return factory.create(); } + get tRange(): DurationRange { + const t1 = this.tRangeSources[0].moment.time; + const t2 = this.tRangeSources[1].moment.time; + return new DurationRange(t1, t2); + } + + get xRange(): util.NumberRange { + const x1 = this.toXRangeBound(this.xRangeSources[0]); + const x2 = this.toXRangeBound(this.xRangeSources[1]); + return new util.NumberRange(x1, x2); + } + + get yRange(): util.NumberRange { + const y1 = this.getYRangeBound(this.yRangeSources[0]); + const y2 = this.getYRangeBound(this.yRangeSources[1]); + return new util.NumberRange(y1, y2); + } + getHints(previousFrame: CursorFrame): CursorFrameHint[] { return [...this.getRetriggerHints(previousFrame), ...this.getSustainHints(previousFrame)]; } + toHumanReadable(): string[] { + const tRangeDescription = this.describer.describeTRange(this.tRangeSources); + const xRangeDescription = this.describer.describeXRange(this.xRangeSources); + const yRangeDescription = this.describer.describeYRange(this.yRangeSources); + + return [`t: ${tRangeDescription}`, `x: ${xRangeDescription}`, `y: ${yRangeDescription}`]; + } + + private toXRangeBound(source: XRangeSource): number { + switch (source.type) { + case 'system': + return source.bound === 'left' ? source.system.rect().left() : source.system.rect().right(); + case 'measure': + return source.bound === 'left' ? source.measure.rect().left() : source.measure.rect().right(); + case 'element': + return source.bound === 'left' ? source.element.rect().left() : source.element.rect().right(); + } + } + + private getYRangeBound(source: YRangeSource): number { + return source.bound === 'top' ? source.part.rect().top() : source.part.rect().bottom(); + } + private getRetriggerHints(previousFrame: CursorFrame): RetriggerHint[] { const hints = new Array(); if (this === previousFrame) { @@ -91,25 +148,148 @@ export class CursorFrame { class CursorFrameFactory { private frames = new Array(); private activeElements = new Set(); + private describer: CursorFrameDescriber; - constructor(private score: elements.Score, private timeline: Timeline, private span: CursorVerticalSpan) {} + constructor( + private logger: Logger, + private score: elements.Score, + private timeline: Timeline, + private span: CursorVerticalSpan + ) { + this.describer = CursorFrameDescriber.create(score, timeline.getPartIndex()); + } create(): CursorFrame[] { this.frames = []; this.activeElements = new Set(); for (let index = 0; index < this.timeline.getMomentCount() - 1; index++) { - // const [anchor1, anchor2] = this.identifyAnchorPair(momentIndex); - // const tRange = new DurationRange(currentMoment.time, nextMoment.time); - // const xRange = this.getXRange(currentMoment, currentAnchor, nextAnchor); - // const yRange = this.getYRange(currentAnchor); - // this.updateActiveElements(currentMoment); - // this.addFrame(tRange, xRange, yRange); + const currentMoment = this.timeline.getMoment(index); + const nextMoment = this.timeline.getMoment(index + 1); + util.assertNotNull(currentMoment); + util.assertNotNull(nextMoment); + + const tRangeSources = this.getTRangeSources(currentMoment, nextMoment); + const xRangeSources = this.getXRangeSources(currentMoment, nextMoment); + const yRangeSources = this.getYRangeSources(currentMoment); + + this.updateActiveElements(currentMoment); + + this.addFrame(tRangeSources, xRangeSources, yRangeSources); } return this.frames; } + private getTRangeSources(currentMoment: TimelineMoment, nextMoment: TimelineMoment): [TRangeSource, TRangeSource] { + return [{ moment: currentMoment }, { moment: nextMoment }]; + } + + private getXRangeSources(currentMoment: TimelineMoment, nextMoment: TimelineMoment): [XRangeSource, XRangeSource] { + return [this.getStartXSource(currentMoment), this.getEndXSource(currentMoment, nextMoment)]; + } + + private getStartXSource(moment: TimelineMoment): XRangeSource { + const hasStartingTransition = moment.events.some((e) => e.type === 'transition' && e.kind === 'start'); + if (hasStartingTransition) { + return this.getLeftmostStartingXRangeSource(moment); + } + + this.logger.warn( + 'No starting transition found for moment, ' + + 'but the moment is trying to be used as a starting anchor. ' + + 'How was the moment created?', + { moment } + ); + + const event = moment.events.at(0); + util.assertDefined(event); + + switch (event.type) { + case 'transition': + return { type: 'element', element: event.element, bound: 'left' }; + case 'systemend': + return { type: 'system', system: event.system, bound: 'left' }; + case 'jump': + return { type: 'measure', measure: event.measure, bound: 'left' }; + } + } + + private getEndXSource(currentMoment: TimelineMoment, nextMoment: TimelineMoment): XRangeSource { + const shouldUseMeasureEndBoundary = nextMoment.events.some((e) => e.type === 'jump' || e.type === 'systemend'); + if (shouldUseMeasureEndBoundary) { + const event = currentMoment.events.at(0); + util.assertDefined(event); + + switch (event.type) { + case 'transition': + return { type: 'measure', measure: event.measure, bound: 'right' }; + case 'systemend': + return { type: 'system', system: event.system, bound: 'right' }; + case 'jump': + return { type: 'measure', measure: event.measure, bound: 'right' }; + } + } + + return this.getStartXSource(nextMoment); + } + + private getLeftmostStartingXRangeSource(currentMoment: TimelineMoment): XRangeSource { + const elements = currentMoment.events + .filter((e) => e.type === 'transition') + .filter((e) => e.kind === 'start') + .map((e) => e.element); + + let min = Infinity; + let leftmost: PlaybackElement | undefined = undefined; + for (const element of elements) { + const left = element.rect().left(); + if (left < min) { + min = left; + leftmost = element; + } + } + + util.assertDefined(leftmost); + + return { type: 'element', element: leftmost, bound: 'left' }; + } + + private getYRangeSources(currentMoment: TimelineMoment): [YRangeSource, YRangeSource] { + const systemIndex = this.getSystemIndex(currentMoment); + + const parts = this.score + .getSystems() + .filter((system) => system.getIndex() === systemIndex) + .flatMap((system) => system.getMeasures()) + .flatMap((measure) => measure.getFragments()) + .flatMap((fragment) => fragment.getParts()); + + const topPart = parts.find((part) => part.getIndex() === this.span.fromPartIndex); + const bottomPart = parts.find((part) => part.getIndex() === this.span.toPartIndex); + util.assertDefined(topPart); + util.assertDefined(bottomPart); + + return [ + { part: topPart, bound: 'top' }, + { part: bottomPart, bound: 'bottom' }, + ]; + } + + private getSystemIndex(currentMoment: TimelineMoment): number { + for (const event of currentMoment.events) { + switch (event.type) { + case 'transition': + return event.measure.getSystemIndex(); + case 'systemend': + return event.system.getIndex(); + case 'jump': + return event.measure.getSystemIndex(); + } + } + util.assertUnreachable(); + } + private updateActiveElements(moment: TimelineMoment) { for (const event of moment.events) { if (event.type === 'transition') { @@ -122,28 +302,64 @@ class CursorFrameFactory { } } - private addFrame(tRange: DurationRange, xRange: util.NumberRange, yRange: util.NumberRange): void { - const frame = new CursorFrame(tRange, xRange, yRange, [...this.activeElements]); + private addFrame( + tRangeSources: [TRangeSource, TRangeSource], + xRangeSources: [XRangeSource, XRangeSource], + yRangeSources: [YRangeSource, YRangeSource] + ): void { + const frame = new CursorFrame( + tRangeSources, + xRangeSources, + yRangeSources, + [...this.activeElements], + this.describer + ); this.frames.push(frame); } +} - @util.memoize() - private getYRangeBySystemIndex(): util.NumberRange[] { - const result = new Array(); +class CursorFrameDescriber { + private constructor(private elements: Map) {} - for (const system of this.score.getSystems()) { - const rect = spatial.Rect.merge( - system - .getMeasures() - .flatMap((measure) => measure.getFragments()) - .flatMap((fragment) => fragment.getParts()) - .filter((part) => this.span.fromPartIndex <= part.getIndex() && part.getIndex() <= this.span.toPartIndex) - .map((part) => part.rect()) - ); - const yRange = new util.NumberRange(rect.top(), rect.bottom()); - result.push(yRange); + static create(score: elements.Score, partIndex: number): CursorFrameDescriber { + const elements = new Map(); + score + .getMeasures() + .flatMap((measure) => measure.getFragments()) + .flatMap((fragment) => fragment.getParts().at(partIndex) ?? []) + .flatMap((part) => part.getStaves()) + .flatMap((stave) => stave.getVoices()) + .flatMap((voice) => voice.getEntries()) + .forEach((element, index) => { + elements.set(element, index); + }); + return new CursorFrameDescriber(elements); + } + + describeTRange(tRangeSources: [TRangeSource, TRangeSource]): string { + return `[${tRangeSources[0].moment.time.ms}ms - ${tRangeSources[1].moment.time.ms}ms]`; + } + + describeXRange(xRangeSources: [XRangeSource, XRangeSource]): string { + return `[${this.describeXRangeSource(xRangeSources[0])} - ${this.describeXRangeSource(xRangeSources[1])}]`; + } + + describeYRange(yRangeSources: [YRangeSource, YRangeSource]): string { + return `[${this.describeYRangeSource(yRangeSources[0])} - ${this.describeYRangeSource(yRangeSources[1])}]`; + } + + private describeXRangeSource(source: XRangeSource): string { + switch (source.type) { + case 'system': + return `${source.bound}(system(${source.system.getIndex()}))`; + case 'measure': + return `${source.bound}(measure(${source.measure.getAbsoluteMeasureIndex()}))`; + case 'element': + return `${source.bound}(element(${this.elements.get(source.element)}))`; } + } - return result; + private describeYRangeSource(source: YRangeSource): string { + return `${source.bound}(system(${source.part.getSystemIndex()}), part(${source.part.getIndex()}))`; } } diff --git a/src/playback/index.ts b/src/playback/index.ts index 404ca4069..3b39068b8 100644 --- a/src/playback/index.ts +++ b/src/playback/index.ts @@ -1,4 +1,5 @@ export * from './duration'; +export * from './durationrange'; export * from './timestamplocator'; export * from './timeline'; export * from './legacysequence'; diff --git a/src/playback/timeline.ts b/src/playback/timeline.ts index bb8b32031..f3fe2db53 100644 --- a/src/playback/timeline.ts +++ b/src/playback/timeline.ts @@ -186,9 +186,14 @@ class TimelineFactory { } private upsert(time: Duration, event: TimelineMomentEvent): TimelineMoment { - const moment = this.moments.get(time.ms) ?? { time, events: [] }; - moment.events.push(event); - this.moments.set(time.ms, moment); + let moment: TimelineMoment; + if (this.moments.has(time.ms)) { + moment = this.moments.get(time.ms)!; + moment.events.push(event); + } else { + moment = { time, events: [event] }; + this.moments.set(time.ms, moment); + } return moment; } diff --git a/src/playback/types.ts b/src/playback/types.ts index 82dcfcc13..6cfd0c98a 100644 --- a/src/playback/types.ts +++ b/src/playback/types.ts @@ -14,7 +14,7 @@ export type PlaybackElement = elements.VoiceEntry | elements.Fragment | elements export type TimelineMoment = { time: Duration; - events: TimelineMomentEvent[]; + events: [TimelineMomentEvent, ...TimelineMomentEvent[]]; }; export type TimelineMomentEvent = ElementTransitionEvent | JumpEvent | SystemEndEvent; diff --git a/tests/unit/playback/cursorframe.test.ts b/tests/unit/playback/cursorframe.test.ts index 6a23873ee..f2fda05f5 100644 --- a/tests/unit/playback/cursorframe.test.ts +++ b/tests/unit/playback/cursorframe.test.ts @@ -1,16 +1,46 @@ import * as vexml from '@/index'; import * as path from 'path'; -import fs from 'fs'; import { CursorFrame, Timeline } from '@/playback'; -import { NoopLogger } from '@/debug'; +import { NoopLogger, MemoryLogger } from '@/debug'; +import fs from 'fs'; const DATA_DIR = path.resolve(__dirname, '..', '..', '__data__', 'vexml'); describe(CursorFrame, () => { - it.only('creates for: single measure, single stave, different notes', () => { + let logger: MemoryLogger; + + beforeEach(() => { + logger = new MemoryLogger(); + }); + + it('creates for: single measure, single stave, different notes', () => { const [score, timelines] = render('playback_simple.musicxml'); - const frames = CursorFrame.create(score, timelines[0], { fromPartIndex: 0, toPartIndex: 0 }); + const frames = CursorFrame.create(logger, score, timelines[0], { fromPartIndex: 0, toPartIndex: 0 }); + + expect(logger.getLogs()).toBeEmpty(); + expect(timelines).toHaveLength(1); + expect(frames).toHaveLength(4); + expect(frames[0].toHumanReadable()).toEqual([ + 't: [0ms - 600ms]', + 'x: [left(element(0)) - left(element(1))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[1].toHumanReadable()).toEqual([ + 't: [600ms - 1200ms]', + 'x: [left(element(1)) - left(element(2))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[2].toHumanReadable()).toEqual([ + 't: [1200ms - 1800ms]', + 'x: [left(element(2)) - left(element(3))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[3].toHumanReadable()).toEqual([ + 't: [1800ms - 2400ms]', + 'x: [left(element(3)) - right(measure(0))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); }); // it('creates for: single measure, single stave, same notes', () => { From 48703d53446550d96e3a33c8274a908173b42b33 Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Fri, 4 Apr 2025 05:52:59 -0400 Subject: [PATCH 24/38] test CursorFrame creates for: single measure, single stave, same notes --- src/playback/cursorframe.ts | 1 - tests/unit/playback/cursorframe.test.ts | 32 ++++++++++++++++++++++--- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/playback/cursorframe.ts b/src/playback/cursorframe.ts index 9c1ce1eab..504461633 100644 --- a/src/playback/cursorframe.ts +++ b/src/playback/cursorframe.ts @@ -1,6 +1,5 @@ import * as util from '@/util'; import * as elements from '@/elements'; -import * as spatial from '@/spatial'; import { Logger } from '@/debug'; import { DurationRange } from './durationrange'; import { diff --git a/tests/unit/playback/cursorframe.test.ts b/tests/unit/playback/cursorframe.test.ts index f2fda05f5..0e3ec9b27 100644 --- a/tests/unit/playback/cursorframe.test.ts +++ b/tests/unit/playback/cursorframe.test.ts @@ -43,9 +43,35 @@ describe(CursorFrame, () => { ]); }); - // it('creates for: single measure, single stave, same notes', () => { - // const [score, timelines] = render('playback_same_note.musicxml'); - // }); + it('creates for: single measure, single stave, same notes', () => { + const [score, timelines] = render('playback_same_note.musicxml'); + + const frames = CursorFrame.create(logger, score, timelines[0], { fromPartIndex: 0, toPartIndex: 0 }); + + expect(logger.getLogs()).toBeEmpty(); + expect(timelines).toHaveLength(1); + expect(frames).toHaveLength(4); + expect(frames[0].toHumanReadable()).toEqual([ + 't: [0ms - 600ms]', + 'x: [left(element(0)) - left(element(1))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[1].toHumanReadable()).toEqual([ + 't: [600ms - 1200ms]', + 'x: [left(element(1)) - left(element(2))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[2].toHumanReadable()).toEqual([ + 't: [1200ms - 1800ms]', + 'x: [left(element(2)) - left(element(3))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[3].toHumanReadable()).toEqual([ + 't: [1800ms - 2400ms]', + 'x: [left(element(3)) - right(measure(0))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + }); // it('creates for: single measure, multiple staves, different notes', () => { // const [score, timelines] = render('playback_multi_stave.musicxml'); From 09885e28386b7aaed4038ea09bf06fa52061373b Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Fri, 4 Apr 2025 06:05:06 -0400 Subject: [PATCH 25/38] test CursorFrame creates for: single measure, multiple staves, different notes --- src/playback/cursorframe.ts | 14 +++++++ tests/unit/playback/cursorframe.test.ts | 56 +++++++++++++++++++++++-- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/playback/cursorframe.ts b/src/playback/cursorframe.ts index 504461633..59ad783ce 100644 --- a/src/playback/cursorframe.ts +++ b/src/playback/cursorframe.ts @@ -36,6 +36,20 @@ export class CursorFrame { ) {} static create(logger: Logger, score: elements.Score, timeline: Timeline, span: CursorVerticalSpan): CursorFrame[] { + const partCount = score.getPartCount(); + if (partCount === 0) { + logger.warn('No parts found in score, returning empty cursor frames.'); + return []; + } + + if (0 > span.fromPartIndex || span.fromPartIndex >= partCount) { + throw new Error(`Invalid fromPartIndex: ${span.fromPartIndex}, must be in [0,${partCount - 1}]`); + } + + if (0 > span.toPartIndex || span.toPartIndex >= partCount) { + throw new Error(`Invalid toPartIndex: ${span.toPartIndex}, must be in [0,${partCount - 1}]`); + } + const factory = new CursorFrameFactory(logger, score, timeline, span); return factory.create(); } diff --git a/tests/unit/playback/cursorframe.test.ts b/tests/unit/playback/cursorframe.test.ts index 0e3ec9b27..61c0a2856 100644 --- a/tests/unit/playback/cursorframe.test.ts +++ b/tests/unit/playback/cursorframe.test.ts @@ -21,6 +21,7 @@ describe(CursorFrame, () => { expect(logger.getLogs()).toBeEmpty(); expect(timelines).toHaveLength(1); expect(frames).toHaveLength(4); + // stave0: 0 1 2 3 expect(frames[0].toHumanReadable()).toEqual([ 't: [0ms - 600ms]', 'x: [left(element(0)) - left(element(1))]', @@ -51,6 +52,7 @@ describe(CursorFrame, () => { expect(logger.getLogs()).toBeEmpty(); expect(timelines).toHaveLength(1); expect(frames).toHaveLength(4); + // stave0: 0 1 2 3 expect(frames[0].toHumanReadable()).toEqual([ 't: [0ms - 600ms]', 'x: [left(element(0)) - left(element(1))]', @@ -73,9 +75,57 @@ describe(CursorFrame, () => { ]); }); - // it('creates for: single measure, multiple staves, different notes', () => { - // const [score, timelines] = render('playback_multi_stave.musicxml'); - // }); + it('creates for: single measure, multiple staves, different notes', () => { + const [score, timelines] = render('playback_multi_stave.musicxml'); + + const frames = CursorFrame.create(logger, score, timelines[0], { fromPartIndex: 0, toPartIndex: 0 }); + + expect(logger.getLogs()).toBeEmpty(); + expect(timelines).toHaveLength(1); + expect(frames).toHaveLength(8); + // stave0: 0 1 2 3 + // stave1: 4 5 6 7 8 9 10 11 + expect(frames[0].toHumanReadable()).toEqual([ + 't: [0ms - 300ms]', + 'x: [left(element(0)) - left(element(5))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[1].toHumanReadable()).toEqual([ + 't: [300ms - 600ms]', + 'x: [left(element(5)) - left(element(1))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[2].toHumanReadable()).toEqual([ + 't: [600ms - 900ms]', + 'x: [left(element(1)) - left(element(7))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[3].toHumanReadable()).toEqual([ + 't: [900ms - 1200ms]', + 'x: [left(element(7)) - left(element(2))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[4].toHumanReadable()).toEqual([ + 't: [1200ms - 1500ms]', + 'x: [left(element(2)) - left(element(9))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[5].toHumanReadable()).toEqual([ + 't: [1500ms - 1800ms]', + 'x: [left(element(9)) - left(element(3))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[6].toHumanReadable()).toEqual([ + 't: [1800ms - 2100ms]', + 'x: [left(element(3)) - left(element(11))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[7].toHumanReadable()).toEqual([ + 't: [2100ms - 2400ms]', + 'x: [left(element(11)) - right(measure(0))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + }); // it('creates for: single measure, multiple staves, multiple parts', () => { // const [score, timelines] = render('playback_multi_part.musicxml'); From 318bb23b4b37cc2f6841d50498bf7064f15f42d8 Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Fri, 4 Apr 2025 06:11:39 -0400 Subject: [PATCH 26/38] test CursorFrame creates for: single measure, multiple staves, multiple parts --- tests/unit/playback/cursorframe.test.ts | 62 +++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/tests/unit/playback/cursorframe.test.ts b/tests/unit/playback/cursorframe.test.ts index 61c0a2856..93c1fc8d2 100644 --- a/tests/unit/playback/cursorframe.test.ts +++ b/tests/unit/playback/cursorframe.test.ts @@ -127,9 +127,65 @@ describe(CursorFrame, () => { ]); }); - // it('creates for: single measure, multiple staves, multiple parts', () => { - // const [score, timelines] = render('playback_multi_part.musicxml'); - // }); + it('creates for: single measure, multiple staves, multiple parts', () => { + const [score, timelines] = render('playback_multi_part.musicxml'); + + // This ends up adding test coverage for y-spans. + const span0 = { fromPartIndex: 0, toPartIndex: 0 }; + const span1 = { fromPartIndex: 0, toPartIndex: 1 }; + + const framesPart0 = CursorFrame.create(logger, score, timelines[0], span0); + const framesPart1 = CursorFrame.create(logger, score, timelines[1], span1); + + expect(logger.getLogs()).toBeEmpty(); + expect(timelines).toHaveLength(2); + expect(framesPart0).toHaveLength(4); + // part0, stave0: 0 1 2 3 + expect(framesPart0[0].toHumanReadable()).toEqual([ + 't: [0ms - 600ms]', + 'x: [left(element(0)) - left(element(1))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(framesPart0[1].toHumanReadable()).toEqual([ + 't: [600ms - 1200ms]', + 'x: [left(element(1)) - left(element(2))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(framesPart0[2].toHumanReadable()).toEqual([ + 't: [1200ms - 1800ms]', + 'x: [left(element(2)) - left(element(3))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(framesPart0[3].toHumanReadable()).toEqual([ + 't: [1800ms - 2400ms]', + 'x: [left(element(3)) - right(measure(0))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + + expect(framesPart1).toHaveLength(4); + // part1, stave0: 0 1 2 3 + // part1, stave1: 4 5 6 7 + expect(framesPart1[0].toHumanReadable()).toEqual([ + 't: [0ms - 600ms]', + 'x: [left(element(0)) - left(element(1))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(1))]', + ]); + expect(framesPart1[1].toHumanReadable()).toEqual([ + 't: [600ms - 1200ms]', + 'x: [left(element(1)) - left(element(2))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(1))]', + ]); + expect(framesPart1[2].toHumanReadable()).toEqual([ + 't: [1200ms - 1800ms]', + 'x: [left(element(2)) - left(element(3))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(1))]', + ]); + expect(framesPart1[3].toHumanReadable()).toEqual([ + 't: [1800ms - 2400ms]', + 'x: [left(element(3)) - right(measure(0))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(1))]', + ]); + }); // it('creates for: multiple measures, single stave, different notes', () => { // const [score, timelines] = render('playback_multi_measure.musicxml'); From ad220d5cf9e7b1605ff1991db91ec617aeaa20f4 Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Fri, 4 Apr 2025 06:16:31 -0400 Subject: [PATCH 27/38] test CursorFrame creates for: multiple measures, single stave, different notes --- tests/unit/playback/cursorframe.test.ts | 53 +++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/tests/unit/playback/cursorframe.test.ts b/tests/unit/playback/cursorframe.test.ts index 93c1fc8d2..683678168 100644 --- a/tests/unit/playback/cursorframe.test.ts +++ b/tests/unit/playback/cursorframe.test.ts @@ -187,9 +187,56 @@ describe(CursorFrame, () => { ]); }); - // it('creates for: multiple measures, single stave, different notes', () => { - // const [score, timelines] = render('playback_multi_measure.musicxml'); - // }); + it('creates for: multiple measures, single stave, different notes', () => { + const [score, timelines] = render('playback_multi_measure.musicxml'); + + const frames = CursorFrame.create(logger, score, timelines[0], { fromPartIndex: 0, toPartIndex: 0 }); + + expect(logger.getLogs()).toBeEmpty(); + expect(timelines).toHaveLength(1); + expect(frames).toHaveLength(8); + // stave0: 0 1 2 3 4 | 5 6 7 8 + expect(frames[0].toHumanReadable()).toEqual([ + 't: [0ms - 600ms]', + 'x: [left(element(0)) - left(element(1))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[1].toHumanReadable()).toEqual([ + 't: [600ms - 1200ms]', + 'x: [left(element(1)) - left(element(2))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[2].toHumanReadable()).toEqual([ + 't: [1200ms - 1800ms]', + 'x: [left(element(2)) - left(element(3))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[3].toHumanReadable()).toEqual([ + 't: [1800ms - 2400ms]', + 'x: [left(element(3)) - left(element(4))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[4].toHumanReadable()).toEqual([ + 't: [2400ms - 3000ms]', + 'x: [left(element(4)) - left(element(5))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[5].toHumanReadable()).toEqual([ + 't: [3000ms - 3600ms]', + 'x: [left(element(5)) - left(element(6))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[6].toHumanReadable()).toEqual([ + 't: [3600ms - 4200ms]', + 'x: [left(element(6)) - left(element(7))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[7].toHumanReadable()).toEqual([ + 't: [4200ms - 4800ms]', + 'x: [left(element(7)) - right(measure(1))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + }); // it('creates for: single measure, single stave, repeat', () => { // const [score, timelines] = render('playback_repeat.musicxml'); From 42ed01033284354a660bc27d49aecf7496144a03 Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Fri, 4 Apr 2025 06:20:31 -0400 Subject: [PATCH 28/38] test CursorFrame creates for: single measure, single stave, repeat --- tests/unit/playback/cursorframe.test.ts | 53 +++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/tests/unit/playback/cursorframe.test.ts b/tests/unit/playback/cursorframe.test.ts index 683678168..9ed0c2d94 100644 --- a/tests/unit/playback/cursorframe.test.ts +++ b/tests/unit/playback/cursorframe.test.ts @@ -238,9 +238,56 @@ describe(CursorFrame, () => { ]); }); - // it('creates for: single measure, single stave, repeat', () => { - // const [score, timelines] = render('playback_repeat.musicxml'); - // }); + it('creates for: single measure, single stave, repeat', () => { + const [score, timelines] = render('playback_repeat.musicxml'); + + const frames = CursorFrame.create(logger, score, timelines[0], { fromPartIndex: 0, toPartIndex: 0 }); + + expect(logger.getLogs()).toBeEmpty(); + expect(timelines).toHaveLength(1); + expect(frames).toHaveLength(8); + // stave0: 0 1 2 3 :|| + expect(frames[0].toHumanReadable()).toEqual([ + 't: [0ms - 600ms]', + 'x: [left(element(0)) - left(element(1))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[1].toHumanReadable()).toEqual([ + 't: [600ms - 1200ms]', + 'x: [left(element(1)) - left(element(2))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[2].toHumanReadable()).toEqual([ + 't: [1200ms - 1800ms]', + 'x: [left(element(2)) - left(element(3))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[3].toHumanReadable()).toEqual([ + 't: [1800ms - 2400ms]', + 'x: [left(element(3)) - right(measure(0))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[4].toHumanReadable()).toEqual([ + 't: [2400ms - 3000ms]', + 'x: [left(element(0)) - left(element(1))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[5].toHumanReadable()).toEqual([ + 't: [3000ms - 3600ms]', + 'x: [left(element(1)) - left(element(2))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[6].toHumanReadable()).toEqual([ + 't: [3600ms - 4200ms]', + 'x: [left(element(2)) - left(element(3))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[7].toHumanReadable()).toEqual([ + 't: [4200ms - 4800ms]', + 'x: [left(element(3)) - right(measure(0))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + }); // it('creates for: multiple measures, single stave, repeat with endings', () => { // const [score, timelines] = render('playback_repeat_endings.musicxml'); From f363c8b0ff5386ab020d1394fa22a9cf4e29d42f Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Fri, 4 Apr 2025 06:29:03 -0400 Subject: [PATCH 29/38] test CursorFrame creates for: multiple measures, single stave, repeat with endings --- tests/unit/playback/cursorframe.test.ts | 113 +++++++++++++++++++++++- 1 file changed, 110 insertions(+), 3 deletions(-) diff --git a/tests/unit/playback/cursorframe.test.ts b/tests/unit/playback/cursorframe.test.ts index 9ed0c2d94..aa4fe7910 100644 --- a/tests/unit/playback/cursorframe.test.ts +++ b/tests/unit/playback/cursorframe.test.ts @@ -289,9 +289,116 @@ describe(CursorFrame, () => { ]); }); - // it('creates for: multiple measures, single stave, repeat with endings', () => { - // const [score, timelines] = render('playback_repeat_endings.musicxml'); - // }); + it('creates for: multiple measures, single stave, repeat with endings', () => { + const [score, timelines] = render('playback_repeat_endings.musicxml'); + + const frames = CursorFrame.create(logger, score, timelines[0], { fromPartIndex: 0, toPartIndex: 0 }); + + expect(logger.getLogs()).toBeEmpty(); + expect(timelines).toHaveLength(1); + expect(frames).toHaveLength(20); + // stave0: 0 1 2 3 | [ending1 -> 4 5 6 7] :|| [ending2 -> 8 9 10 11] | 12 13 14 15 + expect(frames[0].toHumanReadable()).toEqual([ + 't: [0ms - 600ms]', + 'x: [left(element(0)) - left(element(1))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[1].toHumanReadable()).toEqual([ + 't: [600ms - 1200ms]', + 'x: [left(element(1)) - left(element(2))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[2].toHumanReadable()).toEqual([ + 't: [1200ms - 1800ms]', + 'x: [left(element(2)) - left(element(3))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[3].toHumanReadable()).toEqual([ + 't: [1800ms - 2400ms]', + 'x: [left(element(3)) - left(element(4))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[4].toHumanReadable()).toEqual([ + 't: [2400ms - 3000ms]', + 'x: [left(element(4)) - left(element(5))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[5].toHumanReadable()).toEqual([ + 't: [3000ms - 3600ms]', + 'x: [left(element(5)) - left(element(6))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[6].toHumanReadable()).toEqual([ + 't: [3600ms - 4200ms]', + 'x: [left(element(6)) - left(element(7))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[7].toHumanReadable()).toEqual([ + 't: [4200ms - 4800ms]', + 'x: [left(element(7)) - right(measure(1))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[8].toHumanReadable()).toEqual([ + 't: [4800ms - 5400ms]', + 'x: [left(element(0)) - left(element(1))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[9].toHumanReadable()).toEqual([ + 't: [5400ms - 6000ms]', + 'x: [left(element(1)) - left(element(2))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[10].toHumanReadable()).toEqual([ + 't: [6000ms - 6600ms]', + 'x: [left(element(2)) - left(element(3))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[11].toHumanReadable()).toEqual([ + 't: [6600ms - 7200ms]', + 'x: [left(element(3)) - right(measure(0))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[12].toHumanReadable()).toEqual([ + 't: [7200ms - 7800ms]', + 'x: [left(element(8)) - left(element(9))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[13].toHumanReadable()).toEqual([ + 't: [7800ms - 8400ms]', + 'x: [left(element(9)) - left(element(10))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[14].toHumanReadable()).toEqual([ + 't: [8400ms - 9000ms]', + 'x: [left(element(10)) - left(element(11))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[15].toHumanReadable()).toEqual([ + 't: [9000ms - 9600ms]', + 'x: [left(element(11)) - left(element(12))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[16].toHumanReadable()).toEqual([ + 't: [9600ms - 10200ms]', + 'x: [left(element(12)) - left(element(13))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[17].toHumanReadable()).toEqual([ + 't: [10200ms - 10800ms]', + 'x: [left(element(13)) - left(element(14))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[18].toHumanReadable()).toEqual([ + 't: [10800ms - 11400ms]', + 'x: [left(element(14)) - left(element(15))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[19].toHumanReadable()).toEqual([ + 't: [11400ms - 12000ms]', + 'x: [left(element(15)) - right(measure(3))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + }); // it('creates for: multiple measures, single stave, multiple systems', () => { // const [score, timelines] = render('playback_multi_system.musicxml'); From 69acf5f238c9c73335fae1f9145afb24ef9fb24b Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Fri, 4 Apr 2025 07:07:53 -0400 Subject: [PATCH 30/38] test CursorFrame creates for: multiple measures, single stave, multiple systems --- src/playback/cursorframe.ts | 14 ++++-- tests/unit/playback/cursorframe.test.ts | 59 +++++++++++++++++++++++-- 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/src/playback/cursorframe.ts b/src/playback/cursorframe.ts index 59ad783ce..9b59ef9f2 100644 --- a/src/playback/cursorframe.ts +++ b/src/playback/cursorframe.ts @@ -273,8 +273,8 @@ class CursorFrameFactory { const parts = this.score .getSystems() - .filter((system) => system.getIndex() === systemIndex) - .flatMap((system) => system.getMeasures()) + .at(systemIndex)! + .getMeasures() .flatMap((measure) => measure.getFragments()) .flatMap((fragment) => fragment.getParts()); @@ -290,7 +290,15 @@ class CursorFrameFactory { } private getSystemIndex(currentMoment: TimelineMoment): number { - for (const event of currentMoment.events) { + const events = currentMoment.events.toSorted((a, b) => { + const kindOrder = { start: 0, stop: 1 }; + if (a.type === 'transition' && b.type === 'transition') { + return kindOrder[a.kind] - kindOrder[b.kind]; + } + const typeOrder = { transition: 0, systemend: 1, jump: 2 }; + return typeOrder[a.type] - typeOrder[b.type]; + }); + for (const event of events) { switch (event.type) { case 'transition': return event.measure.getSystemIndex(); diff --git a/tests/unit/playback/cursorframe.test.ts b/tests/unit/playback/cursorframe.test.ts index aa4fe7910..cedaf1af6 100644 --- a/tests/unit/playback/cursorframe.test.ts +++ b/tests/unit/playback/cursorframe.test.ts @@ -400,9 +400,62 @@ describe(CursorFrame, () => { ]); }); - // it('creates for: multiple measures, single stave, multiple systems', () => { - // const [score, timelines] = render('playback_multi_system.musicxml'); - // }); + it('creates for: multiple measures, single stave, multiple systems', () => { + const [score, timelines] = render('playback_multi_system.musicxml'); + + const frames = CursorFrame.create(logger, score, timelines[0], { fromPartIndex: 0, toPartIndex: 0 }); + + expect(logger.getLogs()).toBeEmpty(); + expect(timelines).toHaveLength(1); + // system0, stave0: 0 | 1 | 2 | 3 | 4 | 5 + // system1, stave0: 6 | 7 | 8 + expect(frames).toHaveLength(9); + expect(frames[0].toHumanReadable()).toEqual([ + 't: [0ms - 2400ms]', + 'x: [left(element(0)) - left(element(1))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[1].toHumanReadable()).toEqual([ + 't: [2400ms - 4800ms]', + 'x: [left(element(1)) - left(element(2))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[2].toHumanReadable()).toEqual([ + 't: [4800ms - 7200ms]', + 'x: [left(element(2)) - left(element(3))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[3].toHumanReadable()).toEqual([ + 't: [7200ms - 9600ms]', + 'x: [left(element(3)) - left(element(4))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[4].toHumanReadable()).toEqual([ + 't: [9600ms - 12000ms]', + 'x: [left(element(4)) - left(element(5))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[5].toHumanReadable()).toEqual([ + 't: [12000ms - 14400ms]', + 'x: [left(element(5)) - right(measure(4))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[6].toHumanReadable()).toEqual([ + 't: [14400ms - 16800ms]', + 'x: [left(element(6)) - left(element(7))]', + 'y: [top(system(1), part(0)) - bottom(system(1), part(0))]', + ]); + expect(frames[7].toHumanReadable()).toEqual([ + 't: [16800ms - 19200ms]', + 'x: [left(element(7)) - left(element(8))]', + 'y: [top(system(1), part(0)) - bottom(system(1), part(0))]', + ]); + expect(frames[8].toHumanReadable()).toEqual([ + 't: [19200ms - 21600ms]', + 'x: [left(element(8)) - right(measure(7))]', + 'y: [top(system(1), part(0)) - bottom(system(1), part(0))]', + ]); + }); }); function render(filename: string): [vexml.Score, Timeline[]] { From 387b7a89fe661e872ccca689fc4fa016b2962de0 Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Fri, 4 Apr 2025 07:31:30 -0400 Subject: [PATCH 31/38] create CursorFrameLocators --- src/playback/bsearchcursorframelocator.ts | 35 ++++++++++++++ src/playback/fastcursorframelocator.ts | 56 +++++++++++++++++++++++ src/playback/types.ts | 5 ++ 3 files changed, 96 insertions(+) create mode 100644 src/playback/bsearchcursorframelocator.ts create mode 100644 src/playback/fastcursorframelocator.ts diff --git a/src/playback/bsearchcursorframelocator.ts b/src/playback/bsearchcursorframelocator.ts new file mode 100644 index 000000000..dbb95b8fa --- /dev/null +++ b/src/playback/bsearchcursorframelocator.ts @@ -0,0 +1,35 @@ +import * as util from '@/util'; +import { CursorFrame } from './cursorframe'; +import { CursorFrameLocator } from './types'; +import { Duration } from './duration'; + +/** + * A CursorFrameLocator that uses binary search to locate the frame at a given time. + */ +export class BSearchCursorFrameLocator implements CursorFrameLocator { + constructor(private frames: CursorFrame[]) {} + + locate(time: Duration): number | null { + let left = 0; + let right = this.frames.length - 1; + + while (left <= right) { + const mid = Math.floor((left + right) / 2); + const entry = this.frames.at(mid); + + util.assertDefined(entry); + + if (entry.tRange.includes(time)) { + return mid; + } + + if (entry.tRange.end.isGreaterThanOrEqual(time)) { + right = mid - 1; + } else { + left = mid + 1; + } + } + + return null; + } +} diff --git a/src/playback/fastcursorframelocator.ts b/src/playback/fastcursorframelocator.ts new file mode 100644 index 000000000..604338ddd --- /dev/null +++ b/src/playback/fastcursorframelocator.ts @@ -0,0 +1,56 @@ +import { CursorFrame } from './cursorframe'; +import { Duration } from './duration'; +import { CursorFrameLocator } from './types'; + +/** + * A CursorFrameLocator that uses O(1) time complexity to locate the frame at a given time before falling back to a more + * expensive locator. + */ +export class FastCursorFrameLocator implements CursorFrameLocator { + private index = 0; + + constructor(private frames: CursorFrame[], private fallback: CursorFrameLocator) {} + + locate(time: Duration): number | null { + if (time.isLessThan(Duration.zero())) { + return this.update(0); + } + + if (time.isGreaterThan(this.getDuration())) { + return this.update(this.frames.length - 1); + } + + const previousIndex = this.index - 1; + if (previousIndex >= 0 && this.frames.at(previousIndex)?.tRange.includes(time)) { + return this.update(previousIndex); + } + + const currentIndex = this.index; + if (this.frames.at(currentIndex)?.tRange.includes(time)) { + return this.update(currentIndex); + } + + const nextIndex = this.index + 1; + if (this.frames.at(nextIndex)?.tRange.includes(time)) { + return this.update(nextIndex); + } + + const index = this.fallback.locate(time); + if (typeof index === 'number') { + return this.update(index); + } + + this.update(0); + + return null; + } + + private update(index: number): number { + this.index = index; + return index; + } + + private getDuration(): Duration { + return this.frames.at(-1)?.tRange.end ?? Duration.zero(); + } +} diff --git a/src/playback/types.ts b/src/playback/types.ts index 6cfd0c98a..a54512c09 100644 --- a/src/playback/types.ts +++ b/src/playback/types.ts @@ -19,6 +19,11 @@ export type TimelineMoment = { export type TimelineMomentEvent = ElementTransitionEvent | JumpEvent | SystemEndEvent; +export interface CursorFrameLocator { + /** Returns the index of the element that is active at the given time. */ + locate(time: Duration): number | null; +} + export type ElementTransitionEvent = { type: 'transition'; kind: 'start' | 'stop'; From 63ae633f01e70a3d0a64daa4bdcec2b249223ab5 Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Fri, 4 Apr 2025 08:12:10 -0400 Subject: [PATCH 32/38] implement new cursor API --- src/playback/cursor.ts | 182 ++++++++++++++++++++++++++++++++++-- src/playback/cursorframe.ts | 4 + src/util/numberrange.ts | 6 ++ 3 files changed, 183 insertions(+), 9 deletions(-) diff --git a/src/playback/cursor.ts b/src/playback/cursor.ts index a75423d0b..d0a0b87a8 100644 --- a/src/playback/cursor.ts +++ b/src/playback/cursor.ts @@ -1,17 +1,42 @@ +import * as elements from '@/elements'; +import * as events from '@/events'; +import * as util from '@/util'; +import { Rect, Point } from '@/spatial'; import { CursorFrame } from './cursorframe'; import { Scroller } from './scroller'; import { Timeline } from './timeline'; -import { CursorVerticalSpan } from './types'; -import * as elements from '@/elements'; +import { CursorFrameLocator, CursorVerticalSpan } from './types'; import { Logger } from '@/debug'; +import { FastCursorFrameLocator } from './fastcursorframelocator'; +import { BSearchCursorFrameLocator } from './bsearchcursorframelocator'; +import { Duration } from './duration'; + +// NOTE: At 2px and below, there is some antialiasing issues on higher resolutions. The cursor will appear to "pulse" as +// it moves. This will happen even when rounding the position. +const CURSOR_WIDTH_PX = 3; + +export type CursorState = { + index: number; + hasNext: boolean; + hasPrevious: boolean; + rect: Rect; + frame: CursorFrame; +}; + +export type CursorEventMap = { + change: CursorState; +}; export class Cursor { - private constructor( - private frames: CursorFrame[], - private scroller: Scroller, - private timeline: Timeline, - private span: CursorVerticalSpan - ) {} + private topic = new events.Topic(); + + private index = 0; + private alpha = 0; // interpolation factor, ranging from 0 to 1 + + private previousIndex = -1; + private previousAlpha = -1; + + private constructor(private frames: CursorFrame[], private locator: CursorFrameLocator, private scroller: Scroller) {} static create( logger: Logger, @@ -21,7 +46,146 @@ export class Cursor { span: CursorVerticalSpan ): Cursor { const frames = CursorFrame.create(logger, score, timeline, span); + const bSearchLocator = new BSearchCursorFrameLocator(frames); + const fastLocator = new FastCursorFrameLocator(frames, bSearchLocator); const scroller = new Scroller(scrollContainer); - return new Cursor(frames, scroller, timeline, span); + return new Cursor(frames, fastLocator, scroller); + } + + getCurrentState(): CursorState { + return this.getState(this.index, this.alpha); + } + + getPreviousState(): CursorState | null { + if (this.previousIndex === -1 || this.previousAlpha === -1) { + return null; + } + return this.getState(this.previousIndex, this.previousAlpha); + } + + next(): void { + if (this.index === this.frames.length - 1) { + this.update(this.index, { alpha: 1 }); + } else { + this.update(this.index + 1, { alpha: 0 }); + } + } + + previous(): void { + this.update(this.index - 1, { alpha: 0 }); + } + + goTo(index: number): void { + this.update(index, { alpha: 0 }); + } + + /** Snaps to the closest sequence entry step. */ + snap(timeMs: number): void { + const time = this.normalize(timeMs); + const index = this.locator.locate(time); + util.assertNotNull(index, 'Cursor frame locator failed to find a frame.'); + this.update(index, { alpha: 0 }); + } + + /** Seeks to the exact position, interpolating as needed. */ + seek(timestampMs: number): void { + const time = this.normalize(timestampMs); + const index = this.locator.locate(time); + util.assertNotNull(index, 'Cursor frame locator failed to find a frame.'); + const entry = this.frames.at(index); + util.assertDefined(entry); + + const left = entry.tRange.start; + const right = entry.tRange.end; + const alpha = (time.ms - left.ms) / (right.ms - left.ms); + + this.update(index, { alpha }); + } + + isFullyVisible(): boolean { + const cursorRect = this.getCurrentState().rect; + return this.scroller.isFullyVisible(cursorRect); + } + + scrollIntoView(behavior: ScrollBehavior = 'auto'): void { + const scrollPoint = this.getScrollPoint(); + this.scroller.scrollTo(scrollPoint, behavior); + } + + addEventListener( + name: N, + listener: events.EventListener, + opts?: { emitBootstrapEvent?: boolean } + ): number { + const id = this.topic.subscribe(name, listener); + if (opts?.emitBootstrapEvent) { + listener(this.getCurrentState()); + } + return id; + } + + removeEventListener(...ids: number[]): void { + for (const id of ids) { + this.topic.unsubscribe(id); + } + } + + removeAllEventListeners(): void { + this.topic.unsubscribeAll(); + } + + private getState(index: number, alpha: number): CursorState { + const frame = this.frames.at(index); + util.assertDefined(frame); + + const rect = this.getCursorRect(frame, alpha); + const hasNext = index < this.frames.length - 1; + const hasPrevious = index > 0; + + return { + index, + hasNext, + hasPrevious, + rect, + frame, + }; + } + + private getScrollPoint(): Point { + const cursorRect = this.getCurrentState().rect; + const x = cursorRect.center().x; + const y = cursorRect.y; + return new Point(x, y); + } + + private normalize(timeMs: number): Duration { + const ms = util.clamp(0, this.getDuration().ms, timeMs); + return Duration.ms(ms); + } + + private getDuration(): Duration { + return this.frames.at(-1)?.tRange.end ?? Duration.zero(); + } + + private getCursorRect(frame: CursorFrame, alpha: number): Rect { + const x = frame.xRange.lerp(alpha); + const y = frame.yRange.start; + const w = CURSOR_WIDTH_PX; + const h = frame.yRange.getSize(); + return new Rect(x, y, w, h); + } + + private update(index: number, { alpha }: { alpha: number }): void { + index = util.clamp(0, this.frames.length - 1, index); + alpha = util.clamp(0, 1, alpha); + // Round to 3 decimal places to avoid overloading the event system with redundant updates. + alpha = Math.round(alpha * 1000) / 1000; + if (index !== this.index || alpha !== this.alpha) { + this.previousIndex = this.index; + this.previousAlpha = this.alpha; + this.index = index; + this.alpha = alpha; + this.topic.publish('change', this.getCurrentState()); + } } } diff --git a/src/playback/cursorframe.ts b/src/playback/cursorframe.ts index 9b59ef9f2..6d27d2ab4 100644 --- a/src/playback/cursorframe.ts +++ b/src/playback/cursorframe.ts @@ -76,6 +76,10 @@ export class CursorFrame { return [...this.getRetriggerHints(previousFrame), ...this.getSustainHints(previousFrame)]; } + getActiveElements(): PlaybackElement[] { + return [...this.activeElements]; + } + toHumanReadable(): string[] { const tRangeDescription = this.describer.describeTRange(this.tRangeSources); const xRangeDescription = this.describer.describeXRange(this.xRangeSources); diff --git a/src/util/numberrange.ts b/src/util/numberrange.ts index bf6fc5f09..07ea889bf 100644 --- a/src/util/numberrange.ts +++ b/src/util/numberrange.ts @@ -1,3 +1,5 @@ +import { lerp } from './math'; + export class NumberRange { public readonly start: number; public readonly end: number; @@ -14,6 +16,10 @@ export class NumberRange { return this.end - this.start; } + lerp(alpha: number): number { + return lerp(this.start, this.end, alpha); + } + includes(value: number): boolean { return value >= this.start && value <= this.end; } From 92a0323a34c57ee750731f61571340a46b0855fa Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Fri, 4 Apr 2025 08:14:28 -0400 Subject: [PATCH 33/38] update README with new cursor API --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 46db5618c..aa2dc7e31 100644 --- a/README.md +++ b/README.md @@ -93,8 +93,8 @@ const cursorComponent = vexml.SimpleCursor.render(score.getOverlayElement()); cursorModel.addEventListener( 'change', (e) => { - cursorComponent.update(e.cursorRect); - // The model infers its visibility via the cursorRect. It assumes you've updated appropriately. + cursorComponent.update(e.rect); + // The model infers its visibility via the rect. It assumes you've updated appropriately. if (!cursorModel.isFullyVisible()) { cursorModel.scrollIntoView(scrollBehavior); } From 376acb38db70528a9e1829c60073d54da6e410b4 Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Fri, 4 Apr 2025 08:15:22 -0400 Subject: [PATCH 34/38] improve clarity on cursor internal indexes --- src/playback/cursor.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/playback/cursor.ts b/src/playback/cursor.ts index d0a0b87a8..58c13d5bd 100644 --- a/src/playback/cursor.ts +++ b/src/playback/cursor.ts @@ -30,8 +30,8 @@ export type CursorEventMap = { export class Cursor { private topic = new events.Topic(); - private index = 0; - private alpha = 0; // interpolation factor, ranging from 0 to 1 + private currentIndex = 0; + private currentAlpha = 0; // interpolation factor, ranging from 0 to 1 private previousIndex = -1; private previousAlpha = -1; @@ -53,7 +53,7 @@ export class Cursor { } getCurrentState(): CursorState { - return this.getState(this.index, this.alpha); + return this.getState(this.currentIndex, this.currentAlpha); } getPreviousState(): CursorState | null { @@ -64,15 +64,15 @@ export class Cursor { } next(): void { - if (this.index === this.frames.length - 1) { - this.update(this.index, { alpha: 1 }); + if (this.currentIndex === this.frames.length - 1) { + this.update(this.currentIndex, { alpha: 1 }); } else { - this.update(this.index + 1, { alpha: 0 }); + this.update(this.currentIndex + 1, { alpha: 0 }); } } previous(): void { - this.update(this.index - 1, { alpha: 0 }); + this.update(this.currentIndex - 1, { alpha: 0 }); } goTo(index: number): void { @@ -180,11 +180,11 @@ 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.index || alpha !== this.alpha) { - this.previousIndex = this.index; - this.previousAlpha = this.alpha; - this.index = index; - this.alpha = alpha; + if (index !== this.currentIndex || alpha !== this.currentAlpha) { + this.previousIndex = this.currentIndex; + this.previousAlpha = this.currentAlpha; + this.currentIndex = index; + this.currentAlpha = alpha; this.topic.publish('change', this.getCurrentState()); } } From 3060097edd5d8f9ebc66f1aecb972f310e47f5af Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Fri, 4 Apr 2025 08:48:14 -0400 Subject: [PATCH 35/38] migrate to new cursor --- site/src/components/Vexml.tsx | 6 ++-- src/elements/score.ts | 35 +++++++++++++++------ src/index.ts | 2 +- src/playback/bsearchcursorframelocator.ts | 10 +++--- src/playback/cursor.ts | 37 +++++++++------------- src/playback/cursorframe.ts | 38 +++++++++++++++++++---- src/playback/cursorpath.ts | 14 +++++++++ src/playback/fastcursorframelocator.ts | 16 +++++----- src/playback/index.ts | 2 ++ src/playback/timeline.ts | 4 +-- src/playback/timestamplocator.ts | 32 ++++++++++--------- 11 files changed, 126 insertions(+), 70 deletions(-) create mode 100644 src/playback/cursorpath.ts diff --git a/site/src/components/Vexml.tsx b/site/src/components/Vexml.tsx index 96e382cb3..af0e10dbe 100644 --- a/site/src/components/Vexml.tsx +++ b/site/src/components/Vexml.tsx @@ -66,7 +66,7 @@ export const Vexml = ({ musicXML, config, onResult, onClick, onLongpress, onEnte if (!cursor.isFullyVisible()) { cursor.scrollIntoView(scrollBehavior); } - const currentTimeMs = cursor.getState().sequenceEntry.durationRange.start.ms; + const currentTimeMs = cursor.getCurrentState().frame.tRange.start.ms; player.seek(currentTimeMs, false); setProgress(currentTimeMs / durationMs); }; @@ -79,7 +79,7 @@ export const Vexml = ({ musicXML, config, onResult, onClick, onLongpress, onEnte if (!cursor.isFullyVisible()) { cursor.scrollIntoView(scrollBehavior); } - const currentTimeMs = cursor.getState().sequenceEntry.durationRange.start.ms; + const currentTimeMs = cursor.getCurrentState().frame.tRange.start.ms; player.seek(currentTimeMs, false); setProgress(currentTimeMs / durationMs); }; @@ -147,7 +147,7 @@ export const Vexml = ({ musicXML, config, onResult, onClick, onLongpress, onEnte cursor.addEventListener( 'change', (e) => { - simpleCursor.update(e.cursorRect); + simpleCursor.update(e.rect); if (!cursor.isFullyVisible()) { cursor.scrollIntoView(scrollBehavior); } diff --git a/src/elements/score.ts b/src/elements/score.ts index 36537bd03..047c3dd77 100644 --- a/src/elements/score.ts +++ b/src/elements/score.ts @@ -17,7 +17,7 @@ import { Measure } from './measure'; /** Score is a rendered musical score. */ export class Score { private isEventsCreated = false; - private cursors = new Array(); + private cursors = new Array(); private constructor( private config: Config, @@ -64,7 +64,7 @@ export class Score { return this.root.getScrollContainer(); } - addCursor(opts?: { partIndex?: number; span?: playback.CursorVerticalSpan }): playback.LegacyCursor { + addCursor(opts?: { partIndex?: number; span?: playback.CursorVerticalSpan }): playback.Cursor { const partCount = this.getPartCount(); const partIndex = opts?.partIndex ?? 0; @@ -75,11 +75,15 @@ export class Score { util.assert(0 <= span.toPartIndex && span.toPartIndex < partCount, 'toPartIndex out of bounds'); util.assert(span.fromPartIndex <= span.toPartIndex, 'fromPartIndex must be less than or equal to toPartIndex'); - const sequence = this.getSequences().find((sequence) => sequence.getPartIndex() === partIndex); - util.assertDefined(sequence); + const timeline = this.getTimelines().find((timeline) => timeline.getPartIndex() === partIndex); + util.assertDefined(timeline); + + const frames = playback.CursorFrame.create(this.log, this, timeline, span); + const path = new playback.CursorPath(partIndex, frames); + const cursor = playback.Cursor.create(path, this.getScrollContainer()); - const cursor = playback.LegacyCursor.create(this.root.getScrollContainer(), this, sequence, span); this.cursors.push(cursor); + return cursor; } @@ -100,7 +104,7 @@ export class Score { /** Returns the duration of the score in milliseconds. */ getDurationMs(): number { - return Math.max(0, ...this.getSequences().map((sequence) => sequence.getDuration().ms)); + return Math.max(0, ...this.getTimelines().map((timeline) => timeline.getDuration().ms)); } /** Returns the max number of parts in this score. */ @@ -347,9 +351,8 @@ export class Score { } @util.memoize() - private getSequences(): playback.LegacySequence[] { - const sequences = new playback.LegacySequenceFactory(this.log, this).create(); - return sequences; + private getTimelines(): playback.Timeline[] { + return playback.Timeline.create(this.log, this); } @util.memoize() @@ -359,6 +362,18 @@ export class Score { @util.memoize() private getTimestampLocator(): playback.TimestampLocator { - return playback.TimestampLocator.create(this, this.getSequences()); + const paths = new Array(); + const timelines = this.getTimelines(); + + for (let partIndex = 0; partIndex < this.getPartCount(); partIndex++) { + const timeline = timelines.find((timeline) => timeline.getPartIndex() === partIndex); + util.assertDefined(timeline); + const span = { fromPartIndex: partIndex, toPartIndex: partIndex }; + const frames = playback.CursorFrame.create(this.log, this, timeline, span); + const path = new playback.CursorPath(partIndex, frames); + paths.push(path); + } + + return playback.TimestampLocator.create(this, paths); } } diff --git a/src/index.ts b/src/index.ts index d4b0c8681..6980dcca9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,5 +29,5 @@ export { } from './formatting'; export { type SchemaDescriptor, type SchemaType, type SchemaConfig } from './schema'; export { SimpleCursor } from './components'; -export { type LegacyCursor as Cursor } from './playback'; +export { type Cursor } from './playback'; export { type Logger, type LogLevel, ConsoleLogger, MemoryLogger, type MemoryLog, NoopLogger } from './debug'; diff --git a/src/playback/bsearchcursorframelocator.ts b/src/playback/bsearchcursorframelocator.ts index dbb95b8fa..a1ae58629 100644 --- a/src/playback/bsearchcursorframelocator.ts +++ b/src/playback/bsearchcursorframelocator.ts @@ -1,21 +1,23 @@ import * as util from '@/util'; -import { CursorFrame } from './cursorframe'; import { CursorFrameLocator } from './types'; import { Duration } from './duration'; +import { CursorPath } from './cursorpath'; /** * A CursorFrameLocator that uses binary search to locate the frame at a given time. */ export class BSearchCursorFrameLocator implements CursorFrameLocator { - constructor(private frames: CursorFrame[]) {} + constructor(private path: CursorPath) {} locate(time: Duration): number | null { + const frames = this.path.getFrames(); + let left = 0; - let right = this.frames.length - 1; + let right = frames.length - 1; while (left <= right) { const mid = Math.floor((left + right) / 2); - const entry = this.frames.at(mid); + const entry = frames.at(mid); util.assertDefined(entry); diff --git a/src/playback/cursor.ts b/src/playback/cursor.ts index 58c13d5bd..c4295fd77 100644 --- a/src/playback/cursor.ts +++ b/src/playback/cursor.ts @@ -1,15 +1,13 @@ -import * as elements from '@/elements'; import * as events from '@/events'; import * as util from '@/util'; import { Rect, Point } from '@/spatial'; import { CursorFrame } from './cursorframe'; import { Scroller } from './scroller'; -import { Timeline } from './timeline'; -import { CursorFrameLocator, CursorVerticalSpan } from './types'; -import { Logger } from '@/debug'; +import { CursorFrameLocator } from './types'; import { FastCursorFrameLocator } from './fastcursorframelocator'; import { BSearchCursorFrameLocator } from './bsearchcursorframelocator'; import { Duration } from './duration'; +import { CursorPath } from './cursorpath'; // NOTE: At 2px and below, there is some antialiasing issues on higher resolutions. The cursor will appear to "pulse" as // it moves. This will happen even when rounding the position. @@ -36,20 +34,13 @@ export class Cursor { private previousIndex = -1; private previousAlpha = -1; - private constructor(private frames: CursorFrame[], private locator: CursorFrameLocator, private scroller: Scroller) {} - - static create( - logger: Logger, - scrollContainer: HTMLElement, - score: elements.Score, - timeline: Timeline, - span: CursorVerticalSpan - ): Cursor { - const frames = CursorFrame.create(logger, score, timeline, span); - const bSearchLocator = new BSearchCursorFrameLocator(frames); - const fastLocator = new FastCursorFrameLocator(frames, bSearchLocator); + private constructor(private path: CursorPath, private locator: CursorFrameLocator, private scroller: Scroller) {} + + static create(path: CursorPath, scrollContainer: HTMLElement): Cursor { + const bSearchLocator = new BSearchCursorFrameLocator(path); + const fastLocator = new FastCursorFrameLocator(path, bSearchLocator); const scroller = new Scroller(scrollContainer); - return new Cursor(frames, fastLocator, scroller); + return new Cursor(path, fastLocator, scroller); } getCurrentState(): CursorState { @@ -64,7 +55,7 @@ export class Cursor { } next(): void { - if (this.currentIndex === this.frames.length - 1) { + if (this.currentIndex === this.path.getFrames().length - 1) { this.update(this.currentIndex, { alpha: 1 }); } else { this.update(this.currentIndex + 1, { alpha: 0 }); @@ -92,7 +83,7 @@ export class Cursor { const time = this.normalize(timestampMs); const index = this.locator.locate(time); util.assertNotNull(index, 'Cursor frame locator failed to find a frame.'); - const entry = this.frames.at(index); + const entry = this.path.getFrames().at(index); util.assertDefined(entry); const left = entry.tRange.start; @@ -135,11 +126,11 @@ export class Cursor { } private getState(index: number, alpha: number): CursorState { - const frame = this.frames.at(index); + const frame = this.path.getFrames().at(index); util.assertDefined(frame); const rect = this.getCursorRect(frame, alpha); - const hasNext = index < this.frames.length - 1; + const hasNext = index < this.path.getFrames().length - 1; const hasPrevious = index > 0; return { @@ -164,7 +155,7 @@ export class Cursor { } private getDuration(): Duration { - return this.frames.at(-1)?.tRange.end ?? Duration.zero(); + return this.path.getFrames().at(-1)?.tRange.end ?? Duration.zero(); } private getCursorRect(frame: CursorFrame, alpha: number): Rect { @@ -176,7 +167,7 @@ export class Cursor { } private update(index: number, { alpha }: { alpha: number }): void { - index = util.clamp(0, this.frames.length - 1, index); + index = util.clamp(0, this.path.getFrames().length - 1, index); alpha = util.clamp(0, 1, alpha); // Round to 3 decimal places to avoid overloading the event system with redundant updates. alpha = Math.round(alpha * 1000) / 1000; diff --git a/src/playback/cursorframe.ts b/src/playback/cursorframe.ts index 6d27d2ab4..d7674eaec 100644 --- a/src/playback/cursorframe.ts +++ b/src/playback/cursorframe.ts @@ -35,10 +35,10 @@ export class CursorFrame { private describer: CursorFrameDescriber ) {} - static create(logger: Logger, score: elements.Score, timeline: Timeline, span: CursorVerticalSpan): CursorFrame[] { + static create(log: Logger, score: elements.Score, timeline: Timeline, span: CursorVerticalSpan): CursorFrame[] { const partCount = score.getPartCount(); if (partCount === 0) { - logger.warn('No parts found in score, returning empty cursor frames.'); + log.warn('No parts found in score, returning empty cursor frames.'); return []; } @@ -50,7 +50,7 @@ export class CursorFrame { throw new Error(`Invalid toPartIndex: ${span.toPartIndex}, must be in [0,${partCount - 1}]`); } - const factory = new CursorFrameFactory(logger, score, timeline, span); + const factory = new CursorFrameFactory(log, score, timeline, span); return factory.create(); } @@ -89,13 +89,39 @@ export class CursorFrame { } private toXRangeBound(source: XRangeSource): number { + const rect = this.getXRangeRect(source); switch (source.type) { case 'system': - return source.bound === 'left' ? source.system.rect().left() : source.system.rect().right(); + return source.bound === 'left' ? rect.left() : rect.right(); case 'measure': - return source.bound === 'left' ? source.measure.rect().left() : source.measure.rect().right(); + return source.bound === 'left' ? rect.left() : rect.right(); case 'element': - return source.bound === 'left' ? source.element.rect().left() : source.element.rect().right(); + return source.bound === 'left' ? rect.left() : rect.right(); + } + } + + private getXRangeRect(source: XRangeSource) { + switch (source.type) { + case 'system': + return ( + source.system + .getMeasures() + .at(0) + ?.getFragments() + .at(0) + ?.getParts() + .at(0) + ?.getStaves() + .at(0) + ?.intrinsicRect() ?? source.system.rect() + ); + case 'measure': + return ( + source.measure.getFragments().at(0)?.getParts().at(0)?.getStaves().at(0)?.intrinsicRect() ?? + source.measure.rect() + ); + case 'element': + return source.element.rect(); } } diff --git a/src/playback/cursorpath.ts b/src/playback/cursorpath.ts new file mode 100644 index 000000000..3136f56cb --- /dev/null +++ b/src/playback/cursorpath.ts @@ -0,0 +1,14 @@ +import { CursorFrame } from './cursorframe'; + +/** A collection of cursor frames for a given part index.. */ +export class CursorPath { + constructor(private partIndex: number, private frames: CursorFrame[]) {} + + getPartIndex(): number { + return this.partIndex; + } + + getFrames(): CursorFrame[] { + return this.frames; + } +} diff --git a/src/playback/fastcursorframelocator.ts b/src/playback/fastcursorframelocator.ts index 604338ddd..bd1e347e6 100644 --- a/src/playback/fastcursorframelocator.ts +++ b/src/playback/fastcursorframelocator.ts @@ -1,4 +1,4 @@ -import { CursorFrame } from './cursorframe'; +import { CursorPath } from './cursorpath'; import { Duration } from './duration'; import { CursorFrameLocator } from './types'; @@ -9,29 +9,31 @@ import { CursorFrameLocator } from './types'; export class FastCursorFrameLocator implements CursorFrameLocator { private index = 0; - constructor(private frames: CursorFrame[], private fallback: CursorFrameLocator) {} + constructor(private path: CursorPath, private fallback: CursorFrameLocator) {} locate(time: Duration): number | null { + const frames = this.path.getFrames(); + if (time.isLessThan(Duration.zero())) { return this.update(0); } if (time.isGreaterThan(this.getDuration())) { - return this.update(this.frames.length - 1); + return this.update(frames.length - 1); } const previousIndex = this.index - 1; - if (previousIndex >= 0 && this.frames.at(previousIndex)?.tRange.includes(time)) { + if (previousIndex >= 0 && frames.at(previousIndex)?.tRange.includes(time)) { return this.update(previousIndex); } const currentIndex = this.index; - if (this.frames.at(currentIndex)?.tRange.includes(time)) { + if (frames.at(currentIndex)?.tRange.includes(time)) { return this.update(currentIndex); } const nextIndex = this.index + 1; - if (this.frames.at(nextIndex)?.tRange.includes(time)) { + if (frames.at(nextIndex)?.tRange.includes(time)) { return this.update(nextIndex); } @@ -51,6 +53,6 @@ export class FastCursorFrameLocator implements CursorFrameLocator { } private getDuration(): Duration { - return this.frames.at(-1)?.tRange.end ?? Duration.zero(); + return this.path.getFrames().at(-1)?.tRange.end ?? Duration.zero(); } } diff --git a/src/playback/index.ts b/src/playback/index.ts index 3b39068b8..e31c1ad2c 100644 --- a/src/playback/index.ts +++ b/src/playback/index.ts @@ -1,3 +1,5 @@ +export * from './cursor'; +export * from './cursorpath'; export * from './duration'; export * from './durationrange'; export * from './timestamplocator'; diff --git a/src/playback/timeline.ts b/src/playback/timeline.ts index f3fe2db53..d2e646fff 100644 --- a/src/playback/timeline.ts +++ b/src/playback/timeline.ts @@ -8,11 +8,11 @@ import * as util from '@/util'; export class Timeline { constructor(private partIndex: number, private moments: TimelineMoment[], private describer: TimelineDescriber) {} - static create(logger: Logger, score: elements.Score): Timeline[] { + static create(log: Logger, score: elements.Score): Timeline[] { const partCount = score.getPartCount(); const timelines = new Array(partCount); for (let partIndex = 0; partIndex < partCount; partIndex++) { - timelines[partIndex] = new TimelineFactory(logger, score, partIndex).create(); + timelines[partIndex] = new TimelineFactory(log, score, partIndex).create(); } return timelines; } diff --git a/src/playback/timestamplocator.ts b/src/playback/timestamplocator.ts index 82cfb3ab2..efe3b11d8 100644 --- a/src/playback/timestamplocator.ts +++ b/src/playback/timestamplocator.ts @@ -2,29 +2,33 @@ import * as spatial from '@/spatial'; import * as elements from '@/elements'; import * as util from '@/util'; import { Duration } from './duration'; -import { LegacySequence } from './legacysequence'; -import { LegacySequenceEntry } from './types'; +import { CursorPath } from './cursorpath'; +import { CursorFrame } from './cursorframe'; type System = { yRange: util.NumberRange; - entries: LegacySequenceEntry[]; + frames: CursorFrame[]; }; export class TimestampLocator { private constructor(private systems: System[]) {} - static create(score: elements.Score, sequences: LegacySequence[]): TimestampLocator { + static create(score: elements.Score, paths: CursorPath[]): TimestampLocator { const systems = score.getSystems().map((system) => { const yRange = new util.NumberRange(system.rect().top(), system.rect().bottom()); - const entries = new Array(); - for (const sequence of sequences) { - entries.push( - ...sequence.getEntries().filter((entry) => entry.anchorElement.getSystemIndex() === system.getIndex()) + const frames = new Array(); + for (const path of paths) { + frames.push( + ...path + .getFrames() + .filter((frame) => + frame.getActiveElements().some((element) => element.getSystemIndex() === system.getIndex()) + ) ); } - return { yRange, entries }; + return { yRange, frames }; }); return new TimestampLocator(systems); @@ -41,13 +45,13 @@ export class TimestampLocator { if (!system.yRange.includes(point.y)) { continue; } - for (const entry of system.entries) { - if (!entry.xRange.includes(point.x)) { + for (const frame of system.frames) { + if (!frame.xRange.includes(point.x)) { continue; } - const startMs = entry.durationRange.start.ms; - const stopMs = entry.durationRange.end.ms; - const alpha = (point.x - entry.xRange.start) / entry.xRange.getSize(); + const startMs = frame.tRange.start.ms; + const stopMs = frame.tRange.end.ms; + const alpha = (point.x - frame.xRange.start) / frame.xRange.getSize(); const timestampMs = util.lerp(startMs, stopMs, alpha); return Duration.ms(timestampMs); } From 2e9991cb3ca07e995d50cefd8df9fd64ecf29290 Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Fri, 4 Apr 2025 08:49:04 -0400 Subject: [PATCH 36/38] delete legacy cursor classes --- src/playback/cheaplocator.ts | 42 --- src/playback/expensivelocator.ts | 34 --- src/playback/index.ts | 3 - src/playback/legacycursor.ts | 244 ---------------- src/playback/legacysequence.ts | 26 -- src/playback/legacysequencefactory.ts | 390 -------------------------- 6 files changed, 739 deletions(-) delete mode 100644 src/playback/cheaplocator.ts delete mode 100644 src/playback/expensivelocator.ts delete mode 100644 src/playback/legacycursor.ts delete mode 100644 src/playback/legacysequence.ts delete mode 100644 src/playback/legacysequencefactory.ts diff --git a/src/playback/cheaplocator.ts b/src/playback/cheaplocator.ts deleted file mode 100644 index aa0f742e2..000000000 --- a/src/playback/cheaplocator.ts +++ /dev/null @@ -1,42 +0,0 @@ -import * as playback from '@/playback'; - -export class CheapLocator { - private sequence: playback.LegacySequence; - private index: number = 0; - - constructor(sequence: playback.LegacySequence) { - this.sequence = sequence; - } - - setStartingIndex(index: number): this { - this.index = index; - return this; - } - - locate(time: playback.Duration): number | null { - if (time.isLessThan(playback.Duration.zero())) { - return 0; - } - - if (time.isGreaterThan(this.sequence.getDuration())) { - return this.sequence.getCount() - 1; - } - - const previousIndex = this.index - 1; - if (this.sequence.getEntry(previousIndex)?.durationRange.includes(time)) { - return previousIndex; - } - - const currentIndex = this.index; - if (this.sequence.getEntry(currentIndex)?.durationRange.includes(time)) { - return currentIndex; - } - - const nextIndex = this.index + 1; - if (this.sequence.getEntry(nextIndex)?.durationRange.includes(time)) { - return nextIndex; - } - - return null; - } -} diff --git a/src/playback/expensivelocator.ts b/src/playback/expensivelocator.ts deleted file mode 100644 index ed0dc7df1..000000000 --- a/src/playback/expensivelocator.ts +++ /dev/null @@ -1,34 +0,0 @@ -import * as playback from '@/playback'; -import * as util from '@/util'; - -export class ExpensiveLocator { - private sequence: playback.LegacySequence; - - constructor(sequence: playback.LegacySequence) { - this.sequence = sequence; - } - - locate(time: playback.Duration): number | null { - let left = 0; - let right = this.sequence.getCount() - 1; - - while (left <= right) { - const mid = Math.floor((left + right) / 2); - const entry = this.sequence.getEntry(mid); - - util.assertNotNull(entry); - - if (entry.durationRange.includes(time)) { - return mid; - } - - if (entry.durationRange.end.isGreaterThanOrEqual(time)) { - right = mid - 1; - } else { - left = mid + 1; - } - } - - return null; - } -} diff --git a/src/playback/index.ts b/src/playback/index.ts index e31c1ad2c..27fa1d10b 100644 --- a/src/playback/index.ts +++ b/src/playback/index.ts @@ -4,8 +4,5 @@ export * from './duration'; export * from './durationrange'; export * from './timestamplocator'; export * from './timeline'; -export * from './legacysequence'; -export * from './legacysequencefactory'; export * from './types'; -export * from './legacycursor'; export * from './cursorframe'; diff --git a/src/playback/legacycursor.ts b/src/playback/legacycursor.ts deleted file mode 100644 index 45af0727c..000000000 --- a/src/playback/legacycursor.ts +++ /dev/null @@ -1,244 +0,0 @@ -import * as playback from '@/playback'; -import * as util from '@/util'; -import * as spatial from '@/spatial'; -import * as events from '@/events'; -import * as elements from '@/elements'; -import { CheapLocator } from './cheaplocator'; -import { ExpensiveLocator } from './expensivelocator'; -import { Scroller } from './scroller'; -import { CursorVerticalSpan } from './types'; - -// NOTE: At 2px and below, there is some antialiasing issues on higher resolutions. The cursor will appear to "pulse" as -// it moves. This will happen even when rounding the position. -const CURSOR_WIDTH_PX = 3; - -type CursorState = { - index: number; - hasNext: boolean; - hasPrevious: boolean; - cursorRect: spatial.Rect; - sequenceEntry: playback.LegacySequenceEntry; -}; - -type EventMap = { - change: CursorState; -}; - -export class LegacyCursor { - private scroller: Scroller; - private states: CursorState[]; - private sequence: playback.LegacySequence; - private cheapLocator: CheapLocator; - private expensiveLocator: ExpensiveLocator; - private span: CursorVerticalSpan; - - private topic = new events.Topic(); - private index = 0; - private alpha = 0; // interpolation factor, ranging from 0 to 1 - - private constructor(opts: { - scroller: Scroller; - states: CursorState[]; - sequence: playback.LegacySequence; - cheapLocator: CheapLocator; - expensiveLocator: ExpensiveLocator; - span: CursorVerticalSpan; - }) { - this.scroller = opts.scroller; - this.states = opts.states; - this.sequence = opts.sequence; - this.cheapLocator = opts.cheapLocator; - this.expensiveLocator = opts.expensiveLocator; - this.span = opts.span; - } - - static create( - scrollContainer: HTMLElement, - score: elements.Score, - sequence: playback.LegacySequence, - span: CursorVerticalSpan - ): LegacyCursor { - // NumberRange objects indexed by system index for the part. - const systemPartYRanges = new Array(); - - for (const system of score.getSystems()) { - const rect = spatial.Rect.merge( - system - .getMeasures() - .flatMap((measure) => measure.getFragments()) - .flatMap((fragment) => fragment.getParts()) - .filter((part) => span.fromPartIndex <= part.getIndex() && part.getIndex() <= span.toPartIndex) - .map((part) => part.rect()) - ); - const yRange = new util.NumberRange(rect.top(), rect.bottom()); - systemPartYRanges.push(yRange); - } - - const states = new Array(sequence.getCount()); - - for (let index = 0; index < sequence.getCount(); index++) { - const sequenceEntry = sequence.getEntry(index); - util.assertNotNull(sequenceEntry); - - const hasPrevious = index > 0; - const hasNext = index < sequence.getCount() - 1; - - const element = sequenceEntry.anchorElement; - - util.assertDefined(element); - - const xRange = sequenceEntry.xRange; - - const systemIndex = element.getSystemIndex(); - const yRange = systemPartYRanges.at(systemIndex); - - util.assertDefined(yRange); - - const x = xRange.start; - const y = yRange.start; - const w = CURSOR_WIDTH_PX; - const h = yRange.getSize(); - - const cursorRect = new spatial.Rect(x, y, w, h); - - states[index] = { - index, - hasPrevious, - hasNext, - cursorRect, - sequenceEntry, - }; - } - - const scroller = new Scroller(scrollContainer); - const cheapLocator = new CheapLocator(sequence); - const expensiveLocator = new ExpensiveLocator(sequence); - - return new LegacyCursor({ - scroller, - states, - sequence, - cheapLocator, - expensiveLocator, - span, - }); - } - - getState(): CursorState { - const state = this.states.at(this.index); - // TODO: We need a way to represent a zero state, when the sequence validly has no entries. Maybe we update the - // signature to be nullable. - util.assertDefined(state); - - if (this.alpha === 0) { - return { ...state }; - } - - const x = util.lerp(state.sequenceEntry.xRange.start, state.sequenceEntry.xRange.end, this.alpha); - const y = state.cursorRect.y; - const w = state.cursorRect.w; - const h = state.cursorRect.h; - const cursorRect = new spatial.Rect(x, y, w, h); - - return { ...state, cursorRect }; - } - - next(): void { - if (this.index === this.sequence.getCount() - 1) { - this.update(this.index, 1); - } else { - this.update(this.index + 1, 0); - } - } - - previous(): void { - this.update(this.index - 1, 0); - } - - goTo(index: number): void { - this.update(index, 0); - } - - /** Snaps to the closest sequence entry step. */ - snap(timestampMs: number): void { - timestampMs = util.clamp(0, this.sequence.getDuration().ms, timestampMs); - const time = playback.Duration.ms(timestampMs); - const index = this.getIndexClosestTo(time); - this.update(index, 0); - } - - /** Seeks to the exact position, interpolating as needed. */ - seek(timestampMs: number): void { - timestampMs = util.clamp(0, this.sequence.getDuration().ms, timestampMs); - const time = playback.Duration.ms(timestampMs); - const index = this.getIndexClosestTo(time); - - const entry = this.sequence.getEntry(index); - util.assertNotNull(entry); - - const left = entry.durationRange.start; - const right = entry.durationRange.end; - const alpha = (time.ms - left.ms) / (right.ms - left.ms); - - this.update(index, alpha); - } - - isFullyVisible(): boolean { - const cursorRect = this.getState().cursorRect; - return this.scroller.isFullyVisible(cursorRect); - } - - scrollIntoView(behavior: ScrollBehavior = 'auto'): void { - const scrollPoint = this.getScrollPoint(); - this.scroller.scrollTo(scrollPoint, behavior); - } - - addEventListener( - name: N, - listener: events.EventListener, - opts?: { emitBootstrapEvent?: boolean } - ): number { - const id = this.topic.subscribe(name, listener); - if (opts?.emitBootstrapEvent) { - listener(this.getState()); - } - return id; - } - - removeEventListener(...ids: number[]): void { - for (const id of ids) { - this.topic.unsubscribe(id); - } - } - - removeAllEventListeners(): void { - this.topic.unsubscribeAll(); - } - - private getScrollPoint(): spatial.Point { - const cursorRect = this.getState().cursorRect; - const x = cursorRect.center().x; - const y = cursorRect.y; - return new spatial.Point(x, y); - } - - private update(index: number, alpha: number): void { - index = util.clamp(0, this.sequence.getCount() - 1, index); - alpha = util.clamp(0, 1, alpha); - // Round to 3 decimal places to avoid overloading the event system with redundant updates. - alpha = Math.round(alpha * 1000) / 1000; - if (index !== this.index || alpha !== this.alpha) { - this.index = index; - this.alpha = alpha; - this.topic.publish('change', this.getState()); - } - } - - private getIndexClosestTo(time: playback.Duration): number { - const index = this.cheapLocator.setStartingIndex(this.index).locate(time) ?? this.expensiveLocator.locate(time); - if (typeof index !== 'number') { - throw new Error(`locator coverage is insufficient to locate time ${time.ms}`); - } - return index; - } -} diff --git a/src/playback/legacysequence.ts b/src/playback/legacysequence.ts deleted file mode 100644 index 90afb488a..000000000 --- a/src/playback/legacysequence.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Duration } from './duration'; -import { LegacySequenceEntry } from './types'; - -export class LegacySequence { - constructor(private partIndex: number, private entries: LegacySequenceEntry[]) {} - - getPartIndex(): number { - return this.partIndex; - } - - getEntry(index: number): LegacySequenceEntry | null { - return this.entries.at(index) ?? null; - } - - getEntries(): LegacySequenceEntry[] { - return this.entries; - } - - getCount(): number { - return this.entries.length; - } - - getDuration(): Duration { - return this.entries.at(-1)?.durationRange.end ?? Duration.zero(); - } -} diff --git a/src/playback/legacysequencefactory.ts b/src/playback/legacysequencefactory.ts deleted file mode 100644 index 21f1ff162..000000000 --- a/src/playback/legacysequencefactory.ts +++ /dev/null @@ -1,390 +0,0 @@ -import * as elements from '@/elements'; -import * as util from '@/util'; -import { NumberRange } from '@/util'; -import { Duration } from './duration'; -import { LegacySequence } from './legacysequence'; -import { PlaybackElement, LegacySequenceEntry } from './types'; -import { DurationRange } from './durationrange'; -import { MeasureSequenceIterator } from './measuresequenceiterator'; -import { Logger } from '@/debug'; - -const LAST_SYSTEM_MEASURE_X_RANGE_PADDING_RIGHT = 10; - -type SequenceEvent = { - type: 'start' | 'stop'; - time: Duration; - element: PlaybackElement; -}; - -export class LegacySequenceFactory { - constructor(private log: Logger, private score: elements.Score) {} - - create(): LegacySequence[] { - const sequences = new Array(); - - const partCount = this.score.getPartCount(); - - for (let partIndex = 0; partIndex < partCount; partIndex++) { - const events = this.getSequenceEvents(partIndex); - const entries = this.toSequenceEntries(events); - const sequence = new LegacySequence(partIndex, entries); - sequences.push(sequence); - } - - return sequences; - } - - private getSequenceEvents(partIndex: number): SequenceEvent[] { - const events = new Array(); - - const measures = this.score.getMeasures().map((measure, index) => ({ - index, - value: measure, - fragments: measure.getFragments(), - jumps: measure.getJumps(), - })); - - const iterator = new MeasureSequenceIterator(measures); - - let measureStartTime = Duration.zero(); - - for (const measureIndex of iterator) { - const measure = measures[measureIndex]; - - let nextMeasureStartTime = measureStartTime; - - if (measure.value.isMultiMeasure()) { - const start = measureStartTime; - const bpm = measure.value.getBpm(); - const duration = Duration.minutes(measure.value.getBeatCount().toDecimal() / bpm); - const stop = start.add(duration); - - events.push({ type: 'start', time: start, element: measure.value }); - events.push({ type: 'stop', time: stop, element: measure.value }); - - // measureStartTime, not nextMeasureStartTime! - measureStartTime = stop; - - continue; - } - - for (const fragment of measure.fragments) { - if (fragment.isNonMusicalGap()) { - const start = measureStartTime; - const duration = Duration.ms(fragment.getNonMusicalDurationMs()); - const stop = start.add(duration); - - events.push({ type: 'start', time: start, element: fragment }); - events.push({ type: 'stop', time: stop, element: fragment }); - - nextMeasureStartTime = stop; - - continue; - } - - const voiceEntries = fragment - .getParts() - .filter((part) => part.getIndex() === partIndex) - .flatMap((fragmentPart) => fragmentPart.getStaves()) - .flatMap((stave) => stave.getVoices()) - .flatMap((voice) => voice.getEntries()); - - const bpm = fragment.getBpm(); - - for (const voiceEntry of voiceEntries) { - // NOTE: getStartMeasureBeat() is relative to the start of the measure. - const start = measureStartTime.add(Duration.minutes(voiceEntry.getStartMeasureBeat().toDecimal() / bpm)); - const duration = Duration.minutes(voiceEntry.getBeatCount().toDecimal() / bpm); - const stop = start.add(duration); - - events.push({ type: 'start', time: start, element: voiceEntry }); - events.push({ type: 'stop', time: stop, element: voiceEntry }); - - if (stop.isGreaterThan(nextMeasureStartTime)) { - nextMeasureStartTime = stop; - } - } - } - - measureStartTime = nextMeasureStartTime; - } - - return events.sort((a, b) => { - if (a.time.ms !== b.time.ms) { - return a.time.ms - b.time.ms; - } - - if (a.type !== b.type) { - // Stop events should come before start events. - return a.type === 'stop' ? -1 : 1; - } - - // If two events occur at the same time and have the same type, sort by x-coordinate. - return a.element.rect().center().x - b.element.rect().center().x; - }); - } - - private toSequenceEntries(events: SequenceEvent[]): LegacySequenceEntry[] { - const measures = this.score.getMeasures(); - const builder = new SequenceEntryBuilder(this.log, measures); - - for (const event of events) { - builder.add(event); - } - - return builder.build(); - } -} - -type XRangeInstruction = - | 'anchor-to-next-event' - | 'activate-only' - | 'terminate-to-measure-end-and-reanchor' - | 'defer-for-interpolation' - | 'ignore'; - -/** SequenceEntryBuilder incrementally transforms SequenceEvents to SequenceEntries. */ -class SequenceEntryBuilder { - private entries = new Array(); - private anchor: PlaybackElement | null = null; - private active = new Array(); - private pending = new Array(); - private x = -1; - private t = Duration.ms(-1); - private built = false; - - constructor(private log: Logger, private measures: elements.Measure[]) {} - - add(event: SequenceEvent): void { - if (event.type === 'start') { - this.start(event); - } else { - this.stop(event); - } - } - - build(): LegacySequenceEntry[] { - util.assert(!this.built, 'SequenceEntryBuilder has already built'); - - if (this.anchor && this.pending.length > 0) { - // We account for the last stop event by using the time of the last entry as a start bound. - const event = this.pending.at(-1)!; - const x1 = this.x; - const x2 = this.getMeasureRightBoundaryX(event.element); - const t1 = this.t; - const t2 = event.time; - this.push(x1, x2, t1, t2, this.anchor, this.active); - } - - this.built = true; - - return this.entries; - } - - private start(event: SequenceEvent): void { - if (this.anchor) { - const instruction = this.getXRangeInstruction(this.anchor, event.element); - - if (instruction === 'anchor-to-next-event') { - let x1 = this.x; - const x2 = this.getLeftBoundaryX(event.element); - const t1 = this.t; - const t2 = event.time; - - if (x1 > x2) { - // See https://github.com/stringsync/vexml/issues/264 for context. - this.log.warn('encountered a sequence-building issue where x1 > x2, forcing a fix', { - x1, - x2, - x: this.x, - absoluteMeasureIndex: event.element.getAbsoluteMeasureIndex(), - }); - x1 = this.anchor.rect().center().x; - } - - this.processPending(new NumberRange(x1, x2), t1); - this.active.push(event.element); - this.push(x1, x2, t1, t2, this.anchor, this.active); - - this.x = x2; - this.t = t2; - } else if (instruction === 'terminate-to-measure-end-and-reanchor') { - const x1 = this.x; - const x2 = this.getMeasureRightBoundaryX(this.anchor); - const t1 = this.t; - const t2 = event.time; - - this.processPending(new NumberRange(x1, x2), t1); - this.active.push(event.element); - this.push(x1, x2, t1, t2, this.anchor, this.active); - - this.x = this.getLeftBoundaryX(event.element); - this.t = t2; - } else if (instruction === 'defer-for-interpolation') { - this.pending.push(event); - } else if (instruction === 'ignore') { - // noop - } else if (instruction === 'activate-only') { - this.entries.at(-1)?.activeElements.push(event.element); - this.active.push(event.element); - } else { - util.assertUnreachable(); - } - } else { - this.x = this.getLeftBoundaryX(event.element); - this.t = event.time; - } - - this.anchor = event.element; - } - - private stop(event: SequenceEvent): void { - // A stop event does not provide a closing x-range boundary, so we don't know where to terminate the in-flight - // sequence entry. We'll enqueue it for now, and then process it once we have a start event that can provide the - // closing x-range boundary. - this.pending.push(event); - } - - private processPending(xRange: NumberRange, t1: Duration): void { - // Now that we have a closing x-range boundary, we can process the pending events that occurred. - while (this.pending.length > 0) { - const event = this.pending.shift()!; - - const alpha = (event.time.ms - t1.ms) / xRange.getSize(); - - const x1 = this.x; - const x2 = util.lerp(xRange.start, xRange.end, alpha); - // t1 is given - const t2 = event.time; - - if (event.type === 'start') { - if (x2 < xRange.end && t2.isLessThan(t1)) { - this.push(x1, x2, t1, t2, this.anchor!, this.active); - } - this.active.push(event.element); - } else { - if (x2 < xRange.end && t2.isLessThan(t1)) { - this.push(x1, x2, t1, t2, this.anchor!, this.active); - } - this.active.splice(this.active.indexOf(event.element), 1); - } - - this.x = x2; - } - } - - private push( - x1: number, - x2: number, - t1: Duration, - t2: Duration, - anchor: PlaybackElement, - active: PlaybackElement[] - ): void { - const durationRange = new DurationRange(t1, t2); - const xRange = new NumberRange(x1, x2); - this.entries.push({ durationRange, xRange, anchorElement: anchor, activeElements: [...active] }); - } - - private getLeftBoundaryX(element: PlaybackElement): number { - switch (element.name) { - case 'fragment': - return this.getFragmentLeftBoundaryX(element); - case 'measure': - return this.getMeasureLeftBoundaryX(element); - case 'note': - case 'rest': - return this.getVoiceEntryBoundaryX(element); - default: - util.assertUnreachable(); - } - } - - private getMeasureRightBoundaryX(element: PlaybackElement): number { - const measure = this.measures.find((measure) => - measure.includesAbsoluteMeasureIndex(element.getAbsoluteMeasureIndex()) - ); - util.assertDefined(measure); - - let result = measure.rect().right(); - if (measure.isLastMeasureInSystem()) { - result -= LAST_SYSTEM_MEASURE_X_RANGE_PADDING_RIGHT; - } - return result; - } - - private getFragmentLeftBoundaryX(fragment: elements.Fragment): number { - return ( - fragment - .getParts() - .flatMap((part) => part.getStaves()) - .map((stave) => stave.playableRect().left()) - .at(0) ?? fragment.rect().left() - ); - } - - private getMeasureLeftBoundaryX(measure: elements.Measure): number { - const fragment = measure.getFragments().at(0); - if (fragment) { - return this.getFragmentLeftBoundaryX(fragment); - } - return measure.rect().left(); - } - - private getVoiceEntryBoundaryX(voiceEntry: elements.VoiceEntry): number { - return voiceEntry.rect().center().x; - } - - private getXRangeInstruction(previous: PlaybackElement, current: PlaybackElement): XRangeInstruction { - const systemIndex1 = previous.getSystemIndex(); - const systemIndex2 = current.getSystemIndex(); - const measureIndex1 = previous.getAbsoluteMeasureIndex(); - const measureIndex2 = current.getAbsoluteMeasureIndex(); - const startMeasureBeat1 = previous.getStartMeasureBeat(); - const startMeasureBeat2 = current.getStartMeasureBeat(); - - const x1 = previous.rect().center().x; - const x2 = current.rect().center().x; - - if (x1 === x2) { - // This is common when a part has multiple staves. When elements have the same x-coordinate, we'll just add the - // current element to the active list. - return 'activate-only'; - } - - const isProgressingNormallyInTheSameMeasure = - measureIndex1 === measureIndex2 && startMeasureBeat1.isLessThan(startMeasureBeat2); - const isProgressingNormallyAcrossMeasures = measureIndex1 + 1 === measureIndex2; - const isProgressingNormally = isProgressingNormallyInTheSameMeasure || isProgressingNormallyAcrossMeasures; - - if (isProgressingNormally && x1 < x2) { - return 'anchor-to-next-event'; - } - - // Below this point, we need to figure out why this is not progressing normally x1 >= x2. - - if (systemIndex1 < systemIndex2) { - return 'terminate-to-measure-end-and-reanchor'; - } - - if (measureIndex1 === measureIndex2 && startMeasureBeat1.isGreaterThanOrEqualTo(startMeasureBeat2)) { - // This is ultimately a formatting issue: the current element is rendered before the previous element, even though - // the current element is played later. In this case, we'll just ignore it and keep progressing until we can find - // a valid movement forward. - return 'defer-for-interpolation'; - } - - if (measureIndex1 > measureIndex2) { - return 'terminate-to-measure-end-and-reanchor'; - } - - // NOTE: Currently, we cannot detect a valid jump forward _in the same measure_. We consider this exceptionally - // rare and playback is not support for this case. - if (measureIndex1 + 1 < measureIndex2) { - return 'terminate-to-measure-end-and-reanchor'; - } - - // At this point, we're in a non-ideal state that isn't covered by any of the cases above. We'll just ignore it. - return 'ignore'; - } -} From 842a988e3abe7a12a5c97e8b6a2ca77e188ff26e Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Sat, 5 Apr 2025 08:57:03 -0400 Subject: [PATCH 37/38] fix issue with multiple repeat endings not excluding previous endings --- src/playback/measuresequenceiterator.ts | 23 +- src/playback/timeline.ts | 23 +- .../vexml/playback_repeat_endings.musicxml | 319 +++++------------- tests/unit/playback/cursorframe.test.ts | 134 ++------ tests/unit/playback/timeline.test.ts | 44 +-- 5 files changed, 162 insertions(+), 381 deletions(-) diff --git a/src/playback/measuresequenceiterator.ts b/src/playback/measuresequenceiterator.ts index 018bcd4ec..984aba679 100644 --- a/src/playback/measuresequenceiterator.ts +++ b/src/playback/measuresequenceiterator.ts @@ -49,17 +49,7 @@ export class MeasureSequenceIterator implements Iterable(); + let i = measureIndex; + while (i > startMeasureIndex && measures[i].jumps.some((jump) => jump.type === 'repeatending')) { + excluding.push(i); + i--; + } + result.push( new Repeat({ id: nextId++, times: 1, from: startMeasureIndex, to: measureIndex, - excluding: [measureIndex], + excluding, }) ); } diff --git a/src/playback/timeline.ts b/src/playback/timeline.ts index d2e646fff..aa8fcf97d 100644 --- a/src/playback/timeline.ts +++ b/src/playback/timeline.ts @@ -175,16 +175,27 @@ class TimelineFactory { private sortEventsWithinMoments(): void { for (const moment of this.moments.values()) { moment.events.sort((a, b) => { - const typeOrder = { - transition: 0, - jump: 1, - systemend: 2, - }; - return typeOrder[a.type] - typeOrder[b.type]; + return this.getEventTypeOrder(a) - this.getEventTypeOrder(b); }); } } + private getEventTypeOrder(event: TimelineMomentEvent): number { + if (event.type === 'transition' && event.kind === 'stop') { + return 0; + } + if (event.type === 'jump') { + return 1; + } + if (event.type === 'systemend') { + return 2; + } + if (event.type === 'transition' && event.kind === 'start') { + return 3; + } + util.assertUnreachable(); + } + private upsert(time: Duration, event: TimelineMomentEvent): TimelineMoment { let moment: TimelineMoment; if (this.moments.has(time.ms)) { diff --git a/tests/__data__/vexml/playback_repeat_endings.musicxml b/tests/__data__/vexml/playback_repeat_endings.musicxml index 073072211..83ba17474 100644 --- a/tests/__data__/vexml/playback_repeat_endings.musicxml +++ b/tests/__data__/vexml/playback_repeat_endings.musicxml @@ -1,237 +1,92 @@ - - - - - Untitled score - - - Composer / arranger - - MuseScore 4.5.0 - 2025-03-17 - - - - - - - - - - Flute - Fl. - - Flute - wind.flutes.flute - - - - 1 - 74 - 78.7402 - 0 - + + + + + Music - - - - - 1 - - 0 - - - - G - 2 - - - - - F - 4 - - 1 - 1 - quarter - up - - - - A - 4 - - 1 - 1 - quarter - up - - - - C - 5 - - 1 - 1 - quarter - down - - - - E - 5 - - 1 - 1 - quarter - down - + + + + + heavy-light + + + + 256 + + 0 + major + + + + G + 2 + + + + + C + 5 + + 1024 + whole + - - - 1. - - - - E - 5 - - 1 - 1 - quarter - down - - - - C - 5 - - 1 - 1 - quarter - down - - - - A - 4 - - 1 - 1 - quarter - up - - - - F - 4 - - 1 - 1 - quarter - up - - - light-heavy - - - + + + + + + + C + 5 + + 1024 + whole + + + light-heavy + + + - - - 2. - - - - G - 4 - - 1 - 1 - quarter - up - - - - B - 4 - - 1 - 1 - quarter - down - - - - D - 5 - - 1 - 1 - quarter - down - - - - F - 5 - - 1 - 1 - quarter - down - - - - + + + + + + + C + 5 + + 1024 + whole + + + light-heavy + + + - - - - F - 5 - - 1 - 1 - quarter - down - - - - D - 5 - - 1 - 1 - quarter - down - - - - B - 4 - - 1 - 1 - quarter - down - - - - G - 4 - - 1 - 1 - quarter - up - - - light-heavy - + + + + + + + C + 5 + + 1024 + whole + + + light-heavy + + - - + + \ No newline at end of file diff --git a/tests/unit/playback/cursorframe.test.ts b/tests/unit/playback/cursorframe.test.ts index cedaf1af6..eecd6de9f 100644 --- a/tests/unit/playback/cursorframe.test.ts +++ b/tests/unit/playback/cursorframe.test.ts @@ -296,108 +296,38 @@ describe(CursorFrame, () => { expect(logger.getLogs()).toBeEmpty(); expect(timelines).toHaveLength(1); - expect(frames).toHaveLength(20); - // stave0: 0 1 2 3 | [ending1 -> 4 5 6 7] :|| [ending2 -> 8 9 10 11] | 12 13 14 15 - expect(frames[0].toHumanReadable()).toEqual([ - 't: [0ms - 600ms]', - 'x: [left(element(0)) - left(element(1))]', - 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', - ]); - expect(frames[1].toHumanReadable()).toEqual([ - 't: [600ms - 1200ms]', - 'x: [left(element(1)) - left(element(2))]', - 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', - ]); - expect(frames[2].toHumanReadable()).toEqual([ - 't: [1200ms - 1800ms]', - 'x: [left(element(2)) - left(element(3))]', - 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', - ]); - expect(frames[3].toHumanReadable()).toEqual([ - 't: [1800ms - 2400ms]', - 'x: [left(element(3)) - left(element(4))]', - 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', - ]); - expect(frames[4].toHumanReadable()).toEqual([ - 't: [2400ms - 3000ms]', - 'x: [left(element(4)) - left(element(5))]', - 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', - ]); - expect(frames[5].toHumanReadable()).toEqual([ - 't: [3000ms - 3600ms]', - 'x: [left(element(5)) - left(element(6))]', - 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', - ]); - expect(frames[6].toHumanReadable()).toEqual([ - 't: [3600ms - 4200ms]', - 'x: [left(element(6)) - left(element(7))]', - 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', - ]); - expect(frames[7].toHumanReadable()).toEqual([ - 't: [4200ms - 4800ms]', - 'x: [left(element(7)) - right(measure(1))]', - 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', - ]); - expect(frames[8].toHumanReadable()).toEqual([ - 't: [4800ms - 5400ms]', - 'x: [left(element(0)) - left(element(1))]', - 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', - ]); - expect(frames[9].toHumanReadable()).toEqual([ - 't: [5400ms - 6000ms]', - 'x: [left(element(1)) - left(element(2))]', - 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', - ]); - expect(frames[10].toHumanReadable()).toEqual([ - 't: [6000ms - 6600ms]', - 'x: [left(element(2)) - left(element(3))]', - 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', - ]); - expect(frames[11].toHumanReadable()).toEqual([ - 't: [6600ms - 7200ms]', - 'x: [left(element(3)) - right(measure(0))]', - 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', - ]); - expect(frames[12].toHumanReadable()).toEqual([ - 't: [7200ms - 7800ms]', - 'x: [left(element(8)) - left(element(9))]', - 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', - ]); - expect(frames[13].toHumanReadable()).toEqual([ - 't: [7800ms - 8400ms]', - 'x: [left(element(9)) - left(element(10))]', - 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', - ]); - expect(frames[14].toHumanReadable()).toEqual([ - 't: [8400ms - 9000ms]', - 'x: [left(element(10)) - left(element(11))]', - 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', - ]); - expect(frames[15].toHumanReadable()).toEqual([ - 't: [9000ms - 9600ms]', - 'x: [left(element(11)) - left(element(12))]', - 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', - ]); - expect(frames[16].toHumanReadable()).toEqual([ - 't: [9600ms - 10200ms]', - 'x: [left(element(12)) - left(element(13))]', - 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', - ]); - expect(frames[17].toHumanReadable()).toEqual([ - 't: [10200ms - 10800ms]', - 'x: [left(element(13)) - left(element(14))]', - 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', - ]); - expect(frames[18].toHumanReadable()).toEqual([ - 't: [10800ms - 11400ms]', - 'x: [left(element(14)) - left(element(15))]', - 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', - ]); - expect(frames[19].toHumanReadable()).toEqual([ - 't: [11400ms - 12000ms]', - 'x: [left(element(15)) - right(measure(3))]', - 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', - ]); + expect(frames).toHaveLength(6); + // stave0: 0 | [ending1 -> 1] :|| [ending2 -> 2] :|| [ending3 -> 3] + // expect(frames[0].toHumanReadable()).toEqual([ + // 't: [0ms - 2400ms]', + // 'x: [left(element(0)) - left(element(1))]', + // 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + // ]); + // expect(frames[1].toHumanReadable()).toEqual([ + // 't: [2400ms - 4800ms]', + // 'x: [left(element(1)) - right(measure(0))]', + // 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + // ]); + // expect(frames[2].toHumanReadable()).toEqual([ + // 't: [4800ms - 7200ms]', + // 'x: [left(element(0)) - right(measure(1))]', + // 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + // ]); + // expect(frames[3].toHumanReadable()).toEqual([ + // 't: [7200ms - 9600ms]', + // 'x: [left(element(2)) - right(measure(0))]', + // 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + // ]); + // expect(frames[4].toHumanReadable()).toEqual([ + // 't: [9600ms - 12000ms]', + // 'x: [left(element(0)) - right(measure(2))]', + // 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + // ]); + // expect(frames[5].toHumanReadable()).toEqual([ + // 't: [12000ms - 14400ms]', + // 'x: [left(element(3)) - right(measure(0))]', + // 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + // ]); }); it('creates for: multiple measures, single stave, multiple systems', () => { diff --git a/tests/unit/playback/timeline.test.ts b/tests/unit/playback/timeline.test.ts index 3a62561f9..5dc72e893 100644 --- a/tests/unit/playback/timeline.test.ts +++ b/tests/unit/playback/timeline.test.ts @@ -52,11 +52,11 @@ describe(Timeline, () => { // stave1: 4 5 6 7 8 9 10 11 '[0ms] start(0), start(4)', '[300ms] stop(4), start(5)', - '[600ms] stop(0), start(1), stop(5), start(6)', + '[600ms] stop(0), stop(5), start(1), start(6)', '[900ms] stop(6), start(7)', - '[1200ms] stop(1), start(2), stop(7), start(8)', + '[1200ms] stop(1), stop(7), start(2), start(8)', '[1500ms] stop(8), start(9)', - '[1800ms] stop(2), start(3), stop(9), start(10)', + '[1800ms] stop(2), stop(9), start(3), start(10)', '[2100ms] stop(10), start(11)', '[2400ms] stop(3), stop(11), systemend', ]); @@ -80,9 +80,9 @@ describe(Timeline, () => { // stave0: 0 1 2 3 // stave1: 4 5 6 7 '[0ms] start(0), start(4)', - '[600ms] stop(0), start(1), stop(4), start(5)', - '[1200ms] stop(1), start(2), stop(5), start(6)', - '[1800ms] stop(2), start(3), stop(6), start(7)', + '[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', ]); }); @@ -119,7 +119,7 @@ describe(Timeline, () => { '[600ms] stop(0), start(1)', '[1200ms] stop(1), start(2)', '[1800ms] stop(2), start(3)', - '[2400ms] stop(3), start(0), jump', + '[2400ms] stop(3), jump, start(0)', '[3000ms] stop(0), start(1)', '[3600ms] stop(1), start(2)', '[4200ms] stop(2), start(3)', @@ -134,28 +134,14 @@ describe(Timeline, () => { expect(timelines).toHaveLength(1); expect(timelines[0].toHumanReadable()).toEqual([ - // stave0: 0 1 2 3 | [ending1 -> 4 5 6 7] :|| [ending2 -> 8 9 10 11] | 12 13 14 15 + // stave0: 0 | [ending1 -> 1] :|| [ending2 -> 2] :|| [ending3 -> 3] '[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), start(0), jump', - '[5400ms] stop(0), start(1)', - '[6000ms] stop(1), start(2)', - '[6600ms] stop(2), start(3)', - '[7200ms] stop(3), start(8), jump', - '[7800ms] stop(8), start(9)', - '[8400ms] stop(9), start(10)', - '[9000ms] stop(10), start(11)', - '[9600ms] stop(11), start(12)', - '[10200ms] stop(12), start(13)', - '[10800ms] stop(13), start(14)', - '[11400ms] stop(14), start(15)', - '[12000ms] stop(15), systemend', + '[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', ]); }); @@ -174,7 +160,7 @@ describe(Timeline, () => { '[7200ms] stop(2), start(3)', '[9600ms] stop(3), start(4)', '[12000ms] stop(4), start(5)', - '[14400ms] stop(5), start(6), systemend', + '[14400ms] stop(5), systemend, start(6)', '[16800ms] stop(6), start(7)', '[19200ms] stop(7), start(8)', '[21600ms] stop(8), systemend', From 50a357544d545c8998321a7cbfbf801f11893fc0 Mon Sep 17 00:00:00 2001 From: Jared Johnson Date: Sat, 5 Apr 2025 18:45:21 -0400 Subject: [PATCH 38/38] simplify CursorFrame and add final test --- src/playback/cursorframe.ts | 6 +-- tests/unit/playback/cursorframe.test.ts | 64 ++++++++++++------------- 2 files changed, 35 insertions(+), 35 deletions(-) diff --git a/src/playback/cursorframe.ts b/src/playback/cursorframe.ts index d7674eaec..0dc132296 100644 --- a/src/playback/cursorframe.ts +++ b/src/playback/cursorframe.ts @@ -229,7 +229,7 @@ class CursorFrameFactory { } private getXRangeSources(currentMoment: TimelineMoment, nextMoment: TimelineMoment): [XRangeSource, XRangeSource] { - return [this.getStartXSource(currentMoment), this.getEndXSource(currentMoment, nextMoment)]; + return [this.getStartXSource(currentMoment), this.getEndXSource(nextMoment)]; } private getStartXSource(moment: TimelineMoment): XRangeSource { @@ -258,10 +258,10 @@ class CursorFrameFactory { } } - private getEndXSource(currentMoment: TimelineMoment, nextMoment: TimelineMoment): XRangeSource { + private getEndXSource(nextMoment: TimelineMoment): XRangeSource { const shouldUseMeasureEndBoundary = nextMoment.events.some((e) => e.type === 'jump' || e.type === 'systemend'); if (shouldUseMeasureEndBoundary) { - const event = currentMoment.events.at(0); + const event = nextMoment.events.at(0); util.assertDefined(event); switch (event.type) { diff --git a/tests/unit/playback/cursorframe.test.ts b/tests/unit/playback/cursorframe.test.ts index eecd6de9f..b8d1663c3 100644 --- a/tests/unit/playback/cursorframe.test.ts +++ b/tests/unit/playback/cursorframe.test.ts @@ -298,36 +298,36 @@ describe(CursorFrame, () => { expect(timelines).toHaveLength(1); expect(frames).toHaveLength(6); // stave0: 0 | [ending1 -> 1] :|| [ending2 -> 2] :|| [ending3 -> 3] - // expect(frames[0].toHumanReadable()).toEqual([ - // 't: [0ms - 2400ms]', - // 'x: [left(element(0)) - left(element(1))]', - // 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', - // ]); - // expect(frames[1].toHumanReadable()).toEqual([ - // 't: [2400ms - 4800ms]', - // 'x: [left(element(1)) - right(measure(0))]', - // 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', - // ]); - // expect(frames[2].toHumanReadable()).toEqual([ - // 't: [4800ms - 7200ms]', - // 'x: [left(element(0)) - right(measure(1))]', - // 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', - // ]); - // expect(frames[3].toHumanReadable()).toEqual([ - // 't: [7200ms - 9600ms]', - // 'x: [left(element(2)) - right(measure(0))]', - // 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', - // ]); - // expect(frames[4].toHumanReadable()).toEqual([ - // 't: [9600ms - 12000ms]', - // 'x: [left(element(0)) - right(measure(2))]', - // 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', - // ]); - // expect(frames[5].toHumanReadable()).toEqual([ - // 't: [12000ms - 14400ms]', - // 'x: [left(element(3)) - right(measure(0))]', - // 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', - // ]); + expect(frames[0].toHumanReadable()).toEqual([ + 't: [0ms - 2400ms]', + 'x: [left(element(0)) - left(element(1))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[1].toHumanReadable()).toEqual([ + 't: [2400ms - 4800ms]', + 'x: [left(element(1)) - right(measure(1))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[2].toHumanReadable()).toEqual([ + 't: [4800ms - 7200ms]', + 'x: [left(element(0)) - right(measure(0))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[3].toHumanReadable()).toEqual([ + 't: [7200ms - 9600ms]', + 'x: [left(element(2)) - right(measure(2))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[4].toHumanReadable()).toEqual([ + 't: [9600ms - 12000ms]', + 'x: [left(element(0)) - right(measure(0))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[5].toHumanReadable()).toEqual([ + 't: [12000ms - 14400ms]', + 'x: [left(element(3)) - right(measure(3))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); }); it('creates for: multiple measures, single stave, multiple systems', () => { @@ -367,7 +367,7 @@ describe(CursorFrame, () => { ]); expect(frames[5].toHumanReadable()).toEqual([ 't: [12000ms - 14400ms]', - 'x: [left(element(5)) - right(measure(4))]', + 'x: [left(element(5)) - right(measure(5))]', 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', ]); expect(frames[6].toHumanReadable()).toEqual([ @@ -382,7 +382,7 @@ describe(CursorFrame, () => { ]); expect(frames[8].toHumanReadable()).toEqual([ 't: [19200ms - 21600ms]', - 'x: [left(element(8)) - right(measure(7))]', + 'x: [left(element(8)) - right(measure(8))]', 'y: [top(system(1), part(0)) - bottom(system(1), part(0))]', ]); });