diff --git a/src/playback/legacymeasuresequenceiterator.ts b/src/playback/legacymeasuresequenceiterator.ts deleted file mode 100644 index 6c0641b09..000000000 --- a/src/playback/legacymeasuresequenceiterator.ts +++ /dev/null @@ -1,217 +0,0 @@ -import * as util from '@/util'; - -type Measure = { - index: number; - jumps: Jump[]; -}; - -type Jump = { type: 'repeatstart' } | { type: 'repeatend'; times: number } | { type: 'repeatending'; times: number }; - -/** - * A class that iterates over measures in playback order (accounting for repeats and jumps). - */ -export class LegacyMeasureSequenceIterator implements Iterable { - private measures: T[]; - - constructor(measures: T[]) { - this.measures = measures; - } - - [Symbol.iterator](): Iterator { - const repeats = Repeat.from(this.measures); - const activeRepeats = new util.Stack(); - - function getState() { - const activeRepeat = activeRepeats.peek(); - - const measureRepeats = repeats.filter((repeat) => repeat.to === measureIndex); - - let measureRepeatIndex = measureRepeats.findIndex((repeat) => activeRepeat?.matches(repeat)); - measureRepeatIndex = measureRepeatIndex === -1 ? 0 : measureRepeatIndex; - - const measureRepeat = measureRepeats.at(measureRepeatIndex); - const nextMeasureRepeat = measureRepeats.at(measureRepeatIndex + 1); - - return { activeRepeat, measureRepeat, nextMeasureRepeat }; - } - - let measureIndex = 0; - - const iterator: Iterator = { - next: () => { - // We've reached the end of the measures. - if (measureIndex >= this.measures.length) { - return { value: null, done: true }; - } - - const measure = this.measures[measureIndex]; - - const { activeRepeat, measureRepeat, nextMeasureRepeat } = getState(); - - const isMeasureExcluded = activeRepeat?.isMeasureExcluded(measureIndex); - if (isMeasureExcluded) { - measureIndex++; - return iterator.next(); - } - - const isMeasureRepeatActive = measureRepeat && activeRepeat && measureRepeat.matches(activeRepeat); - - // The measure repeat is active, has finished, and there is another repeat to process. - if (isMeasureRepeatActive && activeRepeat.isFinished() && nextMeasureRepeat) { - activeRepeats.pop(); - const nextActiveRepeat = nextMeasureRepeat.clone(); - activeRepeats.push(nextActiveRepeat); - nextActiveRepeat.decrement(); - measureIndex = nextMeasureRepeat.from; - return { value: measure.index, done: false }; - } - - // The measure repeat is active, has finished, and there is not another repeat to process. - if (isMeasureRepeatActive && activeRepeat.isFinished() && !nextMeasureRepeat) { - activeRepeats.pop(); - measureIndex++; - return { value: measure.index, done: false }; - } - - // The measure repeat is active and has not finished. - if (isMeasureRepeatActive && !activeRepeat.isFinished()) { - activeRepeat.decrement(); - measureIndex = activeRepeat.from; - return { value: measure.index, done: false }; - } - - // The measure repeat is not active, but it should be. - if (measureRepeat && !measureRepeat.matches(activeRepeat)) { - const nextActiveRepeat = measureRepeat.clone(); - activeRepeats.push(nextActiveRepeat); - nextActiveRepeat.decrement(); - measureIndex = measureRepeat.from; - return { value: measure.index, done: false }; - } - - // Nothing special to do with this measure, move forward. - measureIndex++; - return { value: measure.index, done: false }; - }, - }; - - return iterator; - } -} - -/** A class that conveniently wraps repeat metadata. */ -class Repeat { - public readonly from: number; - public readonly to: number; - - private id: number; - private times: number; - private excluding: number[]; - - private constructor(opts: { id: number; times: number; from: number; to: number; excluding: number[] }) { - this.from = opts.from; - this.to = opts.to; - this.id = opts.id; - this.times = opts.times; - this.excluding = opts.excluding; - } - - static from(measures: Measure[]): Repeat[] { - const result = new Array(); - - let nextId = 1; - const startMeasureIndexes = new util.Stack(); - for (let measureIndex = 0; measureIndex < measures.length; measureIndex++) { - const measure = measures[measureIndex]; - - const hasRepeatEnding = measure.jumps.some((jump) => jump.type === 'repeatending'); - - for (const jump of measure.jumps) { - if (jump.type === 'repeatstart') { - startMeasureIndexes.push(measureIndex); - } - - // We only process repeatends if there is no repeatending in the same measure. - if (!hasRepeatEnding && jump.type === 'repeatend') { - // Not all repeatends have a corresponding repeatstart. Assume they're supposed to repeat from the beginning. - const startMeasureIndex = startMeasureIndexes.pop() ?? 0; - result.push( - new Repeat({ - id: nextId++, - times: jump.times, - from: startMeasureIndex, - to: measureIndex, - excluding: [], - }) - ); - } - - if (jump.type === 'repeatending' && jump.times > 0) { - // Not all repeatendings have a corresponding repeatstart. Assume they're supposed to repeat from the - // beginning. - const startMeasureIndex = startMeasureIndexes.pop() ?? 0; - if (jump.times > 1) { - result.push( - new Repeat({ - id: nextId++, - times: jump.times - 1, - from: startMeasureIndex, - to: measureIndex, - excluding: [], - }) - ); - } - - // Exclude all the previous repeatendings. - const excluding = new Array(); - 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, - }) - ); - } - } - } - - return result; - } - - matches(repeat: Repeat | undefined): boolean { - return this.id === repeat?.id; - } - - isMeasureExcluded(measureIndex: number): boolean { - return this.excluding.includes(measureIndex); - } - - isFinished(): boolean { - return this.times === 0; - } - - decrement() { - if (this.times === 0) { - throw new Error('Cannot decrement a repeat that has already been exhausted.'); - } - this.times--; - } - - clone() { - return new Repeat({ - id: this.id, - times: this.times, - from: this.from, - to: this.to, - excluding: [...this.excluding], - }); - } -} diff --git a/src/playback/measuresequenceiterator.ts b/src/playback/measuresequenceiterator.ts new file mode 100644 index 000000000..7fe5ddfbc --- /dev/null +++ b/src/playback/measuresequenceiterator.ts @@ -0,0 +1,208 @@ +import * as util from '@/util'; + +type Measure = { + index: number; + jumps: Jump[]; +}; + +type Jump = { type: 'repeatstart' } | { type: 'repeatend'; times: number } | { type: 'repeatending'; times: number }; + +/** + * Iterates over measure indices in playback order, expanding repeats and voltas. + * + * Runs in two phases: + * 1. Pre-scan the measures once, building a structural map: which `repeatend`s + * pair with which `repeatstart`s, and which contiguous `repeatending` runs + * form volta groups. + * 2. Walk the measures linearly, consulting the map to decide when to back-jump + * and when to skip an exhausted volta ending. + */ +export class MeasureSequenceIterator implements Iterable { + constructor(private measures: T[]) {} + + [Symbol.iterator](): Iterator { + return computeSequence(this.measures)[Symbol.iterator](); + } +} + +type RepeatEnd = { measureIndex: number; startIndex: number; times: number }; + +type VoltaEnding = { measureIndex: number; times: number; startPass: number; endPass: number }; + +type Volta = { startIndex: number; endings: VoltaEnding[]; totalPasses: number }; + +type Structure = { + repeatEndsByMeasure: Map; + voltas: Volta[]; + endingByMeasure: Map; +}; + +function computeSequence(measures: Measure[]): number[] { + const structure = analyzeStructure(measures); + return walk(measures, structure); +} + +function analyzeStructure(measures: Measure[]): Structure { + const repeatEndsByMeasure = new Map(); + const voltas: Volta[] = []; + const endingByMeasure = new Map(); + + const startStack = new util.Stack(); + let currentVolta: Volta | null = null; + + for (let i = 0; i < measures.length; i++) { + const jumps = measures[i].jumps; + + for (const jump of jumps) { + if (jump.type === 'repeatstart') { + startStack.push(i); + } + } + + const endingJump = findJump(jumps, 'repeatending'); + + if (endingJump) { + if (currentVolta === null) { + currentVolta = { startIndex: startStack.peek() ?? 0, endings: [], totalPasses: 0 }; + voltas.push(currentVolta); + } + const ending: VoltaEnding = { + measureIndex: i, + times: endingJump.times, + startPass: 0, + endPass: 0, + }; + currentVolta.endings.push(ending); + endingByMeasure.set(i, { volta: currentVolta, ending }); + // A `repeatend` co-located with a `repeatending` is intentionally dropped. + continue; + } + + if (currentVolta !== null) { + if (startStack.peek() === currentVolta.startIndex) { + startStack.pop(); + } + currentVolta = null; + } + + const endJump = findJump(jumps, 'repeatend'); + if (endJump) { + const startIndex = startStack.pop() ?? 0; + repeatEndsByMeasure.set(i, { measureIndex: i, startIndex, times: endJump.times }); + } + } + + // Close any volta that runs to the end of the score. + if (currentVolta !== null && startStack.peek() === currentVolta.startIndex) { + startStack.pop(); + } + + for (const volta of voltas) { + // A `repeatending` with `times: 0` on the LAST ending is the standard + // "discontinue" volta: it plays once on the final pass with no back-jump. + // Treat it as `times: 1` for pass-range purposes. + const lastIndex = volta.endings.length - 1; + let pass = 1; + for (let i = 0; i < volta.endings.length; i++) { + const ending = volta.endings[i]; + const effective = i === lastIndex && ending.times === 0 ? 1 : ending.times; + ending.startPass = pass; + ending.endPass = pass + effective - 1; + pass += effective; + } + const sum = pass - 1; + // A single-ending volta whose ending has a back-jump (`times > 0`) needs an + // implicit "+1" pass for the run-past-the-now-exhausted-ending step. In every + // other shape the volta naturally exits on its final ending. + const last = volta.endings[lastIndex]; + const needsImplicitFinalPass = volta.endings.length === 1 && last.times > 0; + volta.totalPasses = needsImplicitFinalPass ? sum + 1 : sum; + } + + return { repeatEndsByMeasure, voltas, endingByMeasure }; +} + +function walk(measures: Measure[], structure: Structure): number[] { + const result: number[] = []; + const remainingBackJumps = new Map(); + const voltaPass = new Map(); + + let i = 0; + while (i < measures.length) { + const endingHit = structure.endingByMeasure.get(i); + + if (endingHit) { + const pass = voltaPass.get(endingHit.volta) ?? 1; + if (pass < endingHit.ending.startPass || pass > endingHit.ending.endPass) { + i++; + continue; + } + } + + result.push(measures[i].index); + + if (endingHit) { + const { volta } = endingHit; + const nextPass = (voltaPass.get(volta) ?? 1) + 1; + if (nextPass > volta.totalPasses) { + voltaPass.delete(volta); + i++; + } else { + voltaPass.set(volta, nextPass); + resetNestedState(structure, remainingBackJumps, voltaPass, volta.startIndex, i); + i = volta.startIndex; + } + continue; + } + + const repeatEnd = structure.repeatEndsByMeasure.get(i); + if (repeatEnd) { + if (repeatEnd.times === 0) { + i++; + continue; + } + const remaining = remainingBackJumps.get(i) ?? repeatEnd.times; + if (remaining > 0) { + remainingBackJumps.set(i, remaining - 1); + resetNestedState(structure, remainingBackJumps, voltaPass, repeatEnd.startIndex, i); + i = repeatEnd.startIndex; + } else { + remainingBackJumps.delete(i); + i++; + } + continue; + } + + i++; + } + + return result; +} + +/** + * Resets state for repeat-ends and voltas nested strictly inside the range we're + * jumping back over, so their counters re-initialize on the next pass through the + * outer block. + */ +function resetNestedState( + structure: Structure, + remainingBackJumps: Map, + voltaPass: Map, + startIndex: number, + endIndex: number +): void { + for (const measureIndex of structure.repeatEndsByMeasure.keys()) { + if (measureIndex > startIndex && measureIndex < endIndex) { + remainingBackJumps.delete(measureIndex); + } + } + for (const volta of structure.voltas) { + if (volta.startIndex > startIndex && volta.startIndex < endIndex) { + voltaPass.delete(volta); + } + } +} + +function findJump(jumps: Jump[], type: K): Extract | undefined { + return jumps.find((jump): jump is Extract => jump.type === type); +} diff --git a/src/playback/timeline.ts b/src/playback/timeline.ts index 43330d6fc..6c3edc006 100644 --- a/src/playback/timeline.ts +++ b/src/playback/timeline.ts @@ -2,7 +2,7 @@ import { Logger } from '@/debug'; import { Duration } from './duration'; import { PlaybackElement, TimelineMoment, TimelineMomentEvent, ElementTransitionEvent } from './types'; import * as elements from '@/elements'; -import { LegacyMeasureSequenceIterator } from './legacymeasuresequenceiterator'; +import { MeasureSequenceIterator } from './measuresequenceiterator'; import * as util from '@/util'; import { ElementDescriber } from './elementdescriber'; @@ -69,7 +69,7 @@ class TimelineFactory { const measures = this.score.getMeasures(); const measureIndexes = Array.from( - new LegacyMeasureSequenceIterator(measures.map((measure, index) => ({ index, jumps: measure.getJumps() }))) + new MeasureSequenceIterator(measures.map((measure, index) => ({ index, jumps: measure.getJumps() }))) ); const result = new Array<{ measure: elements.Measure; willJump: boolean }>(); diff --git a/tests/unit/playback/measuresequenceiterator.test.ts b/tests/unit/playback/measuresequenceiterator.test.ts index 8e2c7282c..1aa5896ef 100644 --- a/tests/unit/playback/measuresequenceiterator.test.ts +++ b/tests/unit/playback/measuresequenceiterator.test.ts @@ -1,14 +1,14 @@ -import { LegacyMeasureSequenceIterator } from '@/playback/legacymeasuresequenceiterator'; +import { MeasureSequenceIterator } from '@/playback/measuresequenceiterator'; -describe(LegacyMeasureSequenceIterator, () => { +describe(MeasureSequenceIterator, () => { it('is empty when there are no measures', () => { - const iterator = new LegacyMeasureSequenceIterator([]); + const iterator = new MeasureSequenceIterator([]); expect(iterator).toBeEmpty(); }); it('is the same as the input when there are no repeats', () => { - const iterator = new LegacyMeasureSequenceIterator([ + const iterator = new MeasureSequenceIterator([ { index: 0, jumps: [] }, { index: 1, jumps: [] }, { index: 2, jumps: [] }, @@ -18,7 +18,7 @@ describe(LegacyMeasureSequenceIterator, () => { }); it('repeats a single measure', () => { - const iterator = new LegacyMeasureSequenceIterator([ + const iterator = new MeasureSequenceIterator([ { index: 0, jumps: [{ type: 'repeatstart' }, { type: 'repeatend', times: 1 }] }, ]); @@ -26,7 +26,7 @@ describe(LegacyMeasureSequenceIterator, () => { }); it('repeats a single measure multiple times', () => { - const iterator = new LegacyMeasureSequenceIterator([ + const iterator = new MeasureSequenceIterator([ { index: 0, jumps: [{ type: 'repeatstart' }, { type: 'repeatend', times: 3 }] }, ]); @@ -34,7 +34,7 @@ describe(LegacyMeasureSequenceIterator, () => { }); it('repeats a single measure when the start is not at the beginning', () => { - const iterator = new LegacyMeasureSequenceIterator([ + const iterator = new MeasureSequenceIterator([ { index: 0, jumps: [] }, { index: 1, jumps: [{ type: 'repeatstart' }] }, { index: 2, jumps: [{ type: 'repeatend', times: 1 }] }, @@ -44,7 +44,7 @@ describe(LegacyMeasureSequenceIterator, () => { }); it('repeats multiple measures', () => { - const iterator = new LegacyMeasureSequenceIterator([ + const iterator = new MeasureSequenceIterator([ { index: 0, jumps: [{ type: 'repeatstart' }] }, { index: 1, jumps: [{ type: 'repeatend', times: 1 }] }, ]); @@ -53,7 +53,7 @@ describe(LegacyMeasureSequenceIterator, () => { }); it('repeats multiple measures multiple times', () => { - const iterator = new LegacyMeasureSequenceIterator([ + const iterator = new MeasureSequenceIterator([ { index: 0, jumps: [{ type: 'repeatstart' }] }, { index: 1, jumps: [{ type: 'repeatend', times: 2 }] }, ]); @@ -62,7 +62,7 @@ describe(LegacyMeasureSequenceIterator, () => { }); it('repeats endings', () => { - const iterator = new LegacyMeasureSequenceIterator([ + const iterator = new MeasureSequenceIterator([ { index: 0, jumps: [{ type: 'repeatstart' }] }, { index: 1, jumps: [{ type: 'repeatending', times: 1 }] }, { index: 2, jumps: [] }, @@ -72,7 +72,7 @@ describe(LegacyMeasureSequenceIterator, () => { }); it('repeats multiple endings', () => { - const iterator = new LegacyMeasureSequenceIterator([ + const iterator = new MeasureSequenceIterator([ { index: 0, jumps: [{ type: 'repeatstart' }] }, { index: 1, jumps: [{ type: 'repeatending', times: 2 }] }, { index: 2, jumps: [] }, @@ -82,7 +82,7 @@ describe(LegacyMeasureSequenceIterator, () => { }); it('handles implicit start repeats', () => { - const iterator = new LegacyMeasureSequenceIterator([ + const iterator = new MeasureSequenceIterator([ { index: 0, jumps: [] }, { index: 1, jumps: [{ type: 'repeatend', times: 1 }] }, ]); @@ -91,7 +91,7 @@ describe(LegacyMeasureSequenceIterator, () => { }); it('handles multiple implicit start repeats', () => { - const iterator = new LegacyMeasureSequenceIterator([ + const iterator = new MeasureSequenceIterator([ { index: 0, jumps: [] }, { index: 1, jumps: [{ type: 'repeatend', times: 1 }] }, { index: 2, jumps: [{ type: 'repeatend', times: 1 }] }, @@ -99,4 +99,91 @@ describe(LegacyMeasureSequenceIterator, () => { expect([...iterator]).toEqual([0, 1, 0, 1, 2, 0, 1, 0, 1, 2]); }); + + it('handles a repeat ending with an implicit start', () => { + const iterator = new MeasureSequenceIterator([ + { index: 0, jumps: [] }, + { index: 1, jumps: [{ type: 'repeatending', times: 1 }] }, + { index: 2, jumps: [] }, + ]); + + expect([...iterator]).toEqual([0, 1, 0, 2]); + }); + + it('continues past a repeat block', () => { + const iterator = new MeasureSequenceIterator([ + { index: 0, jumps: [{ type: 'repeatstart' }] }, + { index: 1, jumps: [{ type: 'repeatend', times: 1 }] }, + { index: 2, jumps: [] }, + { index: 3, jumps: [] }, + ]); + + expect([...iterator]).toEqual([0, 1, 0, 1, 2, 3]); + }); + + it('handles a standalone repeat start with no matching end', () => { + const iterator = new MeasureSequenceIterator([ + { index: 0, jumps: [] }, + { index: 1, jumps: [{ type: 'repeatstart' }] }, + { index: 2, jumps: [] }, + ]); + + expect([...iterator]).toEqual([0, 1, 2]); + }); + + it('handles two non-nested repeats in sequence', () => { + const iterator = new MeasureSequenceIterator([ + { index: 0, jumps: [{ type: 'repeatstart' }] }, + { index: 1, jumps: [{ type: 'repeatend', times: 1 }] }, + { index: 2, jumps: [{ type: 'repeatstart' }] }, + { index: 3, jumps: [{ type: 'repeatend', times: 1 }] }, + ]); + + expect([...iterator]).toEqual([0, 1, 0, 1, 2, 3, 2, 3]); + }); + + it('replays an inner repeat during each pass of an outer repeat', () => { + const iterator = new MeasureSequenceIterator([ + { index: 0, jumps: [{ type: 'repeatstart' }] }, + { index: 1, jumps: [{ type: 'repeatstart' }] }, + { index: 2, jumps: [{ type: 'repeatend', times: 1 }] }, + { index: 3, jumps: [{ type: 'repeatend', times: 1 }] }, + ]); + + expect([...iterator]).toEqual([0, 1, 2, 1, 2, 3, 0, 1, 2, 1, 2, 3]); + }); + + it('plays the 1st ending N times before advancing to the 2nd ending', () => { + // | start | ending(2) | ending(1) | end | + const iterator = new MeasureSequenceIterator([ + { index: 0, jumps: [{ type: 'repeatstart' }] }, + { index: 1, jumps: [{ type: 'repeatending', times: 2 }] }, + { index: 2, jumps: [{ type: 'repeatending', times: 1 }] }, + { index: 3, jumps: [] }, + ]); + + expect([...iterator]).toEqual([0, 1, 0, 1, 0, 2, 3]); + }); + + it('plays three endings in order, each once', () => { + // | start | ending(1) | ending(1) | ending(1) | end | + const iterator = new MeasureSequenceIterator([ + { index: 0, jumps: [{ type: 'repeatstart' }] }, + { index: 1, jumps: [{ type: 'repeatending', times: 1 }] }, + { index: 2, jumps: [{ type: 'repeatending', times: 1 }] }, + { index: 3, jumps: [{ type: 'repeatending', times: 1 }] }, + { index: 4, jumps: [] }, + ]); + + expect([...iterator]).toEqual([0, 1, 0, 2, 0, 3, 4]); + }); + + it('treats a repeatend with times: 0 as a no-op', () => { + const iterator = new MeasureSequenceIterator([ + { index: 0, jumps: [{ type: 'repeatstart' }] }, + { index: 1, jumps: [{ type: 'repeatend', times: 0 }] }, + ]); + + expect([...iterator]).toEqual([0, 1]); + }); });