From 4700c83adcc76f1b5fef2d67807fb295db3f485e Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Wed, 27 Dec 2023 23:16:22 +0100 Subject: [PATCH] Rework Tick Lookup --- src.csharp/AlphaTab.Test/Test/Globals.cs | 2 +- src.csharp/AlphaTab/Collections/List.cs | 2 +- src.csharp/AlphaTab/Core/EcmaScript/Set.cs | 2 + .../kotlin/alphaTab/core/ecmaScript/Set.kt | 3 + src/AlphaTabApiBase.ts | 41 +- src/midi/BeatTickLookup.ts | 63 +- src/midi/MasterBarTickLookup.ts | 337 ++++++++++- src/midi/MidiFileGenerator.ts | 25 +- src/midi/MidiTickLookup.ts | 350 ++++++----- test/audio/MidiTickLookup.test.ts | 566 +++++++++++++++++- 10 files changed, 1149 insertions(+), 242 deletions(-) diff --git a/src.csharp/AlphaTab.Test/Test/Globals.cs b/src.csharp/AlphaTab.Test/Test/Globals.cs index ce5d39f6f..27fa5795e 100644 --- a/src.csharp/AlphaTab.Test/Test/Globals.cs +++ b/src.csharp/AlphaTab.Test/Test/Globals.cs @@ -65,7 +65,7 @@ public Expector(T actual) public NotExpector Not => new(_actual); public Expector Be => this; - public void Equal(object expected, string? message = null) + public void Equal(object? expected, string? message = null) { if (expected is int i && _actual is double) { diff --git a/src.csharp/AlphaTab/Collections/List.cs b/src.csharp/AlphaTab/Collections/List.cs index d176b2990..cf7f73284 100644 --- a/src.csharp/AlphaTab/Collections/List.cs +++ b/src.csharp/AlphaTab/Collections/List.cs @@ -2,7 +2,7 @@ namespace AlphaTab.Collections { - internal class List : System.Collections.Generic.List + internal class List : System.Collections.Generic.List, Iterable { public List(double size) : this(new T[(int)size]) diff --git a/src.csharp/AlphaTab/Core/EcmaScript/Set.cs b/src.csharp/AlphaTab/Core/EcmaScript/Set.cs index fa03971be..d86c7687c 100644 --- a/src.csharp/AlphaTab/Core/EcmaScript/Set.cs +++ b/src.csharp/AlphaTab/Core/EcmaScript/Set.cs @@ -14,6 +14,8 @@ public Set() _data = new HashSet(); } + public double Size => _data.Count; + public Set(IEnumerable values) { _data = new HashSet(values); diff --git a/src.kotlin/alphaTab/alphaTab/src/commonMain/kotlin/alphaTab/core/ecmaScript/Set.kt b/src.kotlin/alphaTab/alphaTab/src/commonMain/kotlin/alphaTab/core/ecmaScript/Set.kt index 329cc08b0..f936c9fdf 100644 --- a/src.kotlin/alphaTab/alphaTab/src/commonMain/kotlin/alphaTab/core/ecmaScript/Set.kt +++ b/src.kotlin/alphaTab/alphaTab/src/commonMain/kotlin/alphaTab/core/ecmaScript/Set.kt @@ -7,6 +7,9 @@ public class Set : Iterable { _set = HashSet() } + public val size : Double + get() = _set.size.toDouble() + public constructor(values: Iterable?) { _set = values?.toHashSet() ?: HashSet() } diff --git a/src/AlphaTabApiBase.ts b/src/AlphaTabApiBase.ts index 7c6726248..833709117 100644 --- a/src/AlphaTabApiBase.ts +++ b/src/AlphaTabApiBase.ts @@ -63,6 +63,7 @@ class SelectionInfo { export class AlphaTabApiBase { private _startTime: number = 0; private _trackIndexes: number[] | null = null; + private _trackIndexLookup: Set | null = null; private _isDestroyed: boolean = false; /** * Gets the UI facade to use for interacting with the user interface. @@ -288,6 +289,7 @@ export class AlphaTabApiBase { for (let track of tracks) { this._trackIndexes.push(track.index); } + this._trackIndexLookup = new Set(this._trackIndexes) this.onScoreLoaded(score); this.loadMidiForScore(); this.render(); @@ -297,6 +299,7 @@ export class AlphaTabApiBase { for (let track of tracks) { this._trackIndexes.push(track.index); } + this._trackIndexLookup = new Set(this._trackIndexes) this.render(); } } @@ -819,7 +822,7 @@ export class AlphaTabApiBase { let currentBeat = this._currentBeat; let tickCache = this._tickCache; if (currentBeat && tickCache) { - this.player!.tickPosition = tickCache.getBeatStart(currentBeat.currentBeat); + this.player!.tickPosition = tickCache.getBeatStart(currentBeat.beat); } } }); @@ -835,8 +838,8 @@ export class AlphaTabApiBase { private cursorUpdateTick(tick: number, stop: boolean, shouldScroll: boolean = false): void { let cache: MidiTickLookup | null = this._tickCache; if (cache) { - let tracks: Track[] = this.tracks; - if (tracks.length > 0) { + let tracks = this._trackIndexLookup; + if (tracks != null && tracks.size > 0) { let beat: MidiTickLookupFindBeatResult | null = cache.findBeat(tracks, tick, this._currentBeat); if (beat) { this.cursorUpdateBeat(beat, stop, shouldScroll); @@ -854,10 +857,10 @@ export class AlphaTabApiBase { shouldScroll: boolean, forceUpdate: boolean = false ): void { - const beat: Beat = lookupResult.currentBeat; - const nextBeat: Beat | null = lookupResult.nextBeat; + const beat: Beat = lookupResult.beat; + const nextBeat: Beat | null = lookupResult.nextBeat?.beat ?? null; const duration: number = lookupResult.duration; - const beatsToHighlight = lookupResult.beatsToHighlight; + const beatsToHighlight = lookupResult.beatLookup.highlightedBeats; if (!beat) { return; @@ -871,7 +874,7 @@ export class AlphaTabApiBase { let previousState: PlayerState | null = this._previousStateForCursor; if ( !forceUpdate && - beat === previousBeat?.currentBeat && + beat === previousBeat?.beat && cache === previousCache && previousState === this._playerState ) { @@ -1026,21 +1029,15 @@ export class AlphaTabApiBase { if (nextBeat) { // if we are moving within the same bar or to the next bar // transition to the next beat, otherwise transition to the end of the bar. + let nextBeatBoundings: BeatBounds | null = cache.findBeat(nextBeat); if ( - (nextBeat.voice.bar.index === beat.voice.bar.index && nextBeat.index > beat.index) || - nextBeat.voice.bar.index === beat.voice.bar.index + 1 + nextBeatBoundings && + nextBeatBoundings.barBounds.masterBarBounds.staveGroupBounds === + barBoundings.staveGroupBounds ) { - let nextBeatBoundings: BeatBounds | null = cache.findBeat(nextBeat); - if ( - nextBeatBoundings && - nextBeatBoundings.barBounds.masterBarBounds.staveGroupBounds === - barBoundings.staveGroupBounds - ) { - nextBeatX = nextBeatBoundings.visualBounds.x; - } + nextBeatX = nextBeatBoundings.visualBounds.x; } } - // we need to put the transition to an own animation frame // otherwise the stop animation above is not applied. this.uiFacade.beginInvoke(() => { @@ -1219,11 +1216,11 @@ export class AlphaTabApiBase { return; } if (range) { - const startBeat = this._tickCache.findBeat(this.tracks, range.startTick); - const endBeat = this._tickCache.findBeat(this.tracks, range.endTick); + const startBeat = this._tickCache.findBeat(this._trackIndexLookup!, range.startTick); + const endBeat = this._tickCache.findBeat(this._trackIndexLookup!, range.endTick); if (startBeat && endBeat) { - const selectionStart = new SelectionInfo(startBeat.currentBeat); - const selectionEnd = new SelectionInfo(endBeat.currentBeat); + const selectionStart = new SelectionInfo(startBeat.beat); + const selectionEnd = new SelectionInfo(endBeat.beat); this.cursorSelectRange(selectionStart, selectionEnd); } } else { diff --git a/src/midi/BeatTickLookup.ts b/src/midi/BeatTickLookup.ts index 20038bdeb..90af0b53d 100644 --- a/src/midi/BeatTickLookup.ts +++ b/src/midi/BeatTickLookup.ts @@ -1,52 +1,77 @@ import { Beat } from '@src/model/Beat'; -import { MasterBarTickLookup } from '@src/midi/MasterBarTickLookup'; /** - * Represents the time period, for which a {@link Beat} is played. + * Represents the time period, for which one or multiple {@link Beat}s are played */ export class BeatTickLookup { private _highlightedBeats: Map = new Map(); /** - * Gets or sets the index of the lookup within the parent MasterBarTickLookup. + * Gets or sets the start time in midi ticks at which the given beat is played. */ - public index: number = 0; + public start: number; /** - * Gets or sets the parent MasterBarTickLookup to which this beat lookup belongs to. + * Gets or sets the end time in midi ticks at which the given beat is played. */ - public masterBar!: MasterBarTickLookup; + public end: number; /** - * Gets or sets the start time in midi ticks at which the given beat is played. + * Gets or sets a list of all beats that should be highlighted when + * the beat of this lookup starts playing. This might not mean + * the beats start at this position. */ - public start: number = 0; + public highlightedBeats: Beat[] = []; /** - * Gets or sets the end time in midi ticks at which the given beat is played. + * Gets the next BeatTickLookup which comes after this one and is in the same + * MasterBarTickLookup. */ - public end: number = 0; + public nextBeat: BeatTickLookup | null = null; /** - * Gets or sets the beat which is played. + * Gets the preivous BeatTickLookup which comes before this one and is in the same + * MasterBarTickLookup. */ - public beat!: Beat; + public previousBeat: BeatTickLookup | null = null; /** - * Gets or sets whether the beat is the placeholder beat for an empty bar. + * Gets the tick duration of this lookup. */ - public isEmptyBar: boolean = false; + public get duration(): number { + return this.end - this.start; + } + + public constructor(start:number, end:number) { + this.start = start; + this.end = end; + } /** - * Gets or sets a list of all beats that should be highlighted when - * the beat of this lookup starts playing. + * Marks the given beat as highlighed as part of this lookup. + * @param beat The beat to add. */ - public beatsToHighlight: Beat[] = []; - public highlightBeat(beat: Beat): void { + if(beat.isEmpty) { + return; + } if (!this._highlightedBeats.has(beat.id)) { this._highlightedBeats.set(beat.id, true); - this.beatsToHighlight.push(beat); + this.highlightedBeats.push(beat); + } + } + + /** + * Looks for the first visible beat which starts at this lookup so it can be used for cursor placement. + * @param visibleTracks The visible tracks. + * @returns The first beat which is visible according to the given tracks or null. + */ + public getVisibleBeatAtStart(visibleTracks: Set): Beat | null { + for (const b of this.highlightedBeats) { + if (b.playbackStart == this.start && visibleTracks.has(b.voice.bar.staff.track.index)) { + return b; + } } + return null; } } diff --git a/src/midi/MasterBarTickLookup.ts b/src/midi/MasterBarTickLookup.ts index ed5b5e16a..5b21e67d7 100644 --- a/src/midi/MasterBarTickLookup.ts +++ b/src/midi/MasterBarTickLookup.ts @@ -1,4 +1,6 @@ +import { AlphaTabError, AlphaTabErrorType } from '@src/AlphaTabError'; import { BeatTickLookup } from '@src/midi/BeatTickLookup'; +import { Beat } from '@src/model/Beat'; import { MasterBar } from '@src/model/MasterBar'; /** @@ -25,11 +27,62 @@ export class MasterBarTickLookup { */ public masterBar!: MasterBar; + public firstBeat: BeatTickLookup | null = null; + public lastBeat: BeatTickLookup | null = null; + + /** - * Gets or sets the list of {@link BeatTickLookup} object which define the durations - * for all {@link Beats} played within the period of this MasterBar. + * Inserts `newNextBeat` after `currentBeat` in the linked list of items and updates. + * the `firstBeat` and `lastBeat` respectively too. + * @param currentBeat The item in which to insert the new item afterwards + * @param newBeat The new item to insert */ - public beats: BeatTickLookup[] = []; + private insertAfter(currentBeat: BeatTickLookup | null, newBeat: BeatTickLookup) { + if (this.firstBeat == null || currentBeat == null || this.lastBeat == null) { + this.firstBeat = newBeat; + this.lastBeat = newBeat; + } else { + // link new node into sequence + newBeat.nextBeat = currentBeat.nextBeat; + newBeat.previousBeat = currentBeat; + + // update this node accordinly + if (currentBeat.nextBeat) { + currentBeat.nextBeat.previousBeat = newBeat; + } + currentBeat.nextBeat = newBeat; + + if (currentBeat == this.lastBeat) { + this.lastBeat = newBeat; + } + } + } + + /** + * Inserts `newNextBeat` before `currentBeat` in the linked list of items and updates. + * the `firstBeat` and `lastBeat` respectively too. + * @param currentBeat The item in which to insert the new item afterwards + * @param newBeat The new item to insert + */ + private insertBefore(currentBeat: BeatTickLookup | null, newBeat: BeatTickLookup) { + if (this.firstBeat == null || currentBeat == null || this.lastBeat == null) { + this.firstBeat = newBeat; + this.lastBeat = newBeat; + } else { + // link new node into sequence + newBeat.previousBeat = currentBeat.previousBeat; + newBeat.nextBeat = currentBeat; + + // update this node accordingly + if (currentBeat.previousBeat) { + currentBeat.previousBeat.nextBeat = newBeat; + } + currentBeat.previousBeat = newBeat; + if (currentBeat == this.firstBeat) { + this.firstBeat = newBeat; + } + } + } /** * Gets or sets the {@link MasterBarTickLookup} of the next masterbar in the {@link Score} @@ -37,21 +90,269 @@ export class MasterBarTickLookup { public nextMasterBar: MasterBarTickLookup | null = null; /** - * Performs the neccessary finalization steps after all information was written. + * Adds a new beat to this masterbar following the slicing logic required by the MidiTickLookup. + * @returns The first item of the chain which was affected. */ - public finish(): void { - this.beats.sort((a, b) => { - return a.start - b.start; - }); - } + public addBeat(beat: Beat, start: number, duration: number) { + const end = start + duration; - /** - * Adds a new {@link BeatTickLookup} to the list of played beats during this MasterBar period. - * @param beat - */ - public addBeat(beat: BeatTickLookup): void { - beat.masterBar = this; - beat.index = this.beats.length; - this.beats.push(beat); + // We have following scenarios we cover overall on inserts + // Technically it would be possible to merge some code paths and work with loops + // to handle all scenarios in a shorter piece of code. + // but this would make the core a lot harder to understand an less readable + // and maintainable for the different scenarios. + // we keep them separate here for that purpose and sacrifice some bytes of code for that. + + // Variant A (initial Insert) + // | New | + // Result A + // | New | + + // Variant B (insert at end, start matches) + // | L1 | L2 | + // | New | + // Result B + // | L1 | L2 | N1 | + + // Variant C (insert at end, with gap) + // | L1 | L2 | + // | New | + // Result C + // | L1 | L2 | N1 | + + + // Variant D (Starts before, ends exactly): + // | L1 | L2 | + // | New | + // Result D: + // | N1 | L1 | L2 | + + // Variant E (Starts before, with gap): + // | L1 | L2 | + // | New | + // Result E: + // | N1 | L1 | L2 | + + // Variant F (starts before, overlaps partially): + // | L1 | L2 | + // | New | + // Result F: + // | N1 | N2 | L1 | L2 | + + // Variant G (starts before, ends the same): + // | L1 | L2 | + // | New | + // Result G: + // | N1 | L1 | L2 | + + // Variant H (starts before, ends after L1): + // | L1 | L2 | + // | New | + // Result H: + // Step 1 (only slice L1): + // | N1 | L1 | L2 | + // Step 2 (call recursively with start time of 'new' adjusted): + // | New | + // | N1 | L1 | N2 | L2 | + + + // Variant I (starts in the middle, ends exactly) + // | L1 | L2 | + // | New | + // Result I + // | N1 | L1 | L2 | + + // Variant J (starts in the middle, ends before) + // | L1 | L2 | + // | New | + // Result J + // |N1| N2 |L1 | L2 | + + // Variant K (starts in the middle, ends after L1) + // | L1 | L2 | + // | New | + // Result K + // Step 1 (only slice L1): + // | N1 | L1 | L2 | + // Step 2 (call recursively with start time of 'new' adjusted): + // | New | + // | N1 | L1 | L2 | + + // Variant L (starts exactly, ends exactly) + // | L1 | L2 | + // | New | + // Result L + // | L1 | L2 | + + // Variant M (starts exactly, ends before) + // | L1 | L2 | + // | New | + // Result M + // | N1 | L1 | L2 | + + // Variant N (starts exactly, ends after L1) + // | L1 | L2 | + // | New | + // Result N + // Step 1 (only update L1): + // | L1 | L2 | + // Step 2 (call recursively with start time of 'new' adjusted): + // | New | + // | L 1 | L2 | + + // Variant A + if (this.firstBeat == null) { + const n1 = new BeatTickLookup(start, end); + n1.highlightBeat(beat); + + this.insertAfter(this.firstBeat, n1); + } + // Variant B + // Variant C + else if (start >= this.lastBeat!.end) { + // using the end here allows merge of B & C + const n1 = new BeatTickLookup(this.lastBeat!.end, end); + n1.highlightBeat(beat); + + this.insertAfter(this.lastBeat, n1); + } + else { + let l1: BeatTickLookup | null = null; + if (start < this.firstBeat.start) { + l1 = this.firstBeat!; + } else { + let current: BeatTickLookup | null = this.firstBeat; + while (current != null) { + // find item where we fall into + if (start >= current.start && start < current.end) { + l1 = current; + break; + } + current = current.nextBeat; + } + + if (l1 === null) { + // should not be possible + throw new AlphaTabError(AlphaTabErrorType.General, "Error on building lookup, unknown variant"); + } + } + + // those scenarios should only happen if we insert before the + // first item (e.g. for grace notes starting < 0) + if (start < l1.start) { + // Variant D + // Variant E + if (end == l1.start) { + // using firstBeat.start here allows merge of D & E + const n1 = new BeatTickLookup(start, l1.start); + n1.highlightBeat(beat); + + this.insertBefore(this.firstBeat, n1); + } + // Variant F + else if (end < l1.end) { + const n1 = new BeatTickLookup(start, l1.start); + n1.highlightBeat(beat); + this.insertBefore(l1, n1); + + const n2 = new BeatTickLookup(l1.start, end); + for (const b of l1.highlightedBeats) { + n2.highlightBeat(b); + } + n2.highlightBeat(beat); + this.insertBefore(l1, n2); + + l1.start = end; + } + // Variant G + else if (end == l1.end) { + const n1 = new BeatTickLookup(start, l1.start); + n1.highlightBeat(beat); + + l1.highlightBeat(beat); + + this.insertBefore(l1, n1); + } + // Variant H + else /* end > this.firstBeat.end */ { + + const n1 = new BeatTickLookup(start, l1.start); + n1.highlightBeat(beat); + + l1.highlightBeat(beat); + + this.insertBefore(l1, n1); + + this.addBeat(beat, l1.end, end - l1.end); + } + } + else if (start > l1.start) { + // variant I + if (end == l1.end) { + const n1 = new BeatTickLookup(l1.start, start); + for (const b of l1.highlightedBeats) { + n1.highlightBeat(b); + } + + l1.start = start; + l1.highlightBeat(beat); + + this.insertBefore(l1, n1) + } + // Variant J + else if (end < l1.end) { + const n1 = new BeatTickLookup(l1.start, start); + this.insertBefore(l1, n1) + + const n2 = new BeatTickLookup(start, end); + this.insertBefore(l1, n2) + + for (const b of l1.highlightedBeats) { + n1.highlightBeat(b) + n2.highlightBeat(b) + } + n2.highlightBeat(beat); + + l1.start = end; + } + // Variant K + else /* end > l1.end */ { + const n1 = new BeatTickLookup(l1.start, start); + for (const b of l1.highlightedBeats) { + n1.highlightBeat(b); + } + + l1.start = start; + l1.highlightBeat(beat); + + this.insertBefore(l1, n1); + + this.addBeat(beat, l1.end, end - l1.end); + } + } + else /* start == l1.start */ { + // Variant L + if (end === l1.end) { + l1.highlightBeat(beat); + } + // Variant M + else if (end < l1.end) { + const n1 = new BeatTickLookup(l1.start, end); + for (const b of l1.highlightedBeats) { + n1.highlightBeat(b); + } + n1.highlightBeat(beat); + + l1.start = end; + + this.insertBefore(l1, n1); + } + // variant N + else /* end > l1.end */ { + l1.highlightBeat(beat); + this.addBeat(beat, l1.end, end - l1.end); + } + } + } } -} +} \ No newline at end of file diff --git a/src/midi/MidiFileGenerator.ts b/src/midi/MidiFileGenerator.ts index 6ce068f6f..a87001acc 100644 --- a/src/midi/MidiFileGenerator.ts +++ b/src/midi/MidiFileGenerator.ts @@ -1,5 +1,3 @@ -import { BeatTickLookup } from '@src/midi/BeatTickLookup'; - import { ControllerType } from '@src/midi/ControllerType'; import { IMidiFileHandler } from '@src/midi/IMidiFileHandler'; @@ -61,7 +59,6 @@ export class MidiFileGenerator { private _settings: Settings; private _handler: IMidiFileHandler; private _currentTempo: number = 0; - private _currentBarRepeatLookup: BeatTickLookup | null = null; private _programsPerChannel: Map = new Map(); /** @@ -135,7 +132,6 @@ export class MidiFileGenerator { this._handler.finishTrack(track.index, controller.currentTick); } - this.tickLookup.finish(); Logger.debug('Midi', 'Midi generation done'); } @@ -232,7 +228,6 @@ export class MidiFileGenerator { private generateBar(bar: Bar, barStartTick: number): void { let playbackBar: Bar = this.getPlaybackBar(bar); - this._currentBarRepeatLookup = null; for (const v of playbackBar.voices) { this.generateVoice(v, barStartTick, bar); @@ -294,29 +289,15 @@ export class MidiFileGenerator { } } - const beatLookup: BeatTickLookup = new BeatTickLookup(); - beatLookup.start = barStartTick + beatStart; - const realTickOffset: number = !beat.nextBeat ? audioDuration : beat.nextBeat.absolutePlaybackStart - beat.absolutePlaybackStart; - beatLookup.end = barStartTick + beatStart; - beatLookup.highlightBeat(beat); - beatLookup.end += realTickOffset > audioDuration ? realTickOffset : audioDuration; - // in case of normal playback register playback if (realBar === beat.voice.bar) { - beatLookup.beat = beat; - this.tickLookup.addBeat(beatLookup); + this.tickLookup.addBeat(beat, beatStart, realTickOffset > audioDuration ? realTickOffset : audioDuration); } else { - beatLookup.isEmptyBar = true; - beatLookup.beat = realBar.voices[0].beats[0]; - if (!this._currentBarRepeatLookup) { - this._currentBarRepeatLookup = beatLookup; - this.tickLookup.addBeat(this._currentBarRepeatLookup); - } else { - this._currentBarRepeatLookup.end = beatLookup.end; - } + // in case of simile marks where we repeat we also register + this.tickLookup.addBeat(beat, 0, realTickOffset > audioDuration ? realTickOffset : audioDuration); } const track: Track = beat.voice.bar.staff.track; diff --git a/src/midi/MidiTickLookup.ts b/src/midi/MidiTickLookup.ts index 147680123..9f64b37cc 100644 --- a/src/midi/MidiTickLookup.ts +++ b/src/midi/MidiTickLookup.ts @@ -3,7 +3,6 @@ import { MasterBarTickLookup } from '@src/midi/MasterBarTickLookup'; import { MidiUtils } from '@src/midi/MidiUtils'; import { Beat } from '@src/model/Beat'; import { MasterBar } from '@src/model/MasterBar'; -import { Track } from '@src/model/Track'; /** * Represents the results of searching the currently played beat. @@ -11,49 +10,81 @@ import { Track } from '@src/model/Track'; */ export class MidiTickLookupFindBeatResult { /** - * Gets or sets the beat that is currently played. + * Gets or sets the beat that is currently played and used for the start + * position of the cursor animation. */ - public get currentBeat(): Beat { - return this.currentBeatLookup.beat; - } + public beat!: Beat; /** - * Gets or sets the beat that will be played next. + * Gets or sets the parent MasterBarTickLookup to which this beat lookup belongs to. */ - public get nextBeat(): Beat | null { - return this.nextBeatLookup?.beat ?? null; - } + public masterBar: MasterBarTickLookup; /** - * Gets or sets the duration in milliseconds how long this beat is playing. + * Gets or sets the related beat tick lookup. */ - public duration: number = 0; + public beatLookup!: BeatTickLookup; /** - * Gets or sets the duration in midi ticks for how long this tick lookup is valid - * starting at the `currentBeatLookup.start` + * Gets or sets the beat that will be played next. */ - public tickDuration: number = 0; + public nextBeat: MidiTickLookupFindBeatResult | null = null; /** - * Gets or sets the beats ot highlight along the current beat. + * Gets or sets the duration in midi ticks how long this lookup is valid. */ - public beatsToHighlight!: Beat[]; + public tickDuration: number = 0; /** - * Gets or sets the underlying beat lookup which - * was used for building this MidiTickLookupFindBeatResult. + * Gets or sets the duration in milliseconds how long this lookup is valid. */ - public currentBeatLookup!: BeatTickLookup; + public duration: number = 0; - /** - * Gets or sets the beat lookup for the next beat. - */ - public nextBeatLookup: BeatTickLookup | null = null; + public get start(): number { + return this.masterBar.start + this.beatLookup.start; + } + + public get end(): number { + return this.start + this.tickDuration; + } + + public constructor(masterBar: MasterBarTickLookup) { + this.masterBar = masterBar; + } } /** * This class holds all information about when {@link MasterBar}s and {@link Beat}s are played. + * + * On top level it is organized into {@link MasterBarTickLookup} objects indicating the + * master bar start and end times. This information is used to highlight the currently played bars + * and it gives access to the played beats in this masterbar and their times. + * + * The {@link BeatTickLookup} are then the slices into which the masterbar is separated by the voices and beats + * of all tracks. An example how things are organized: + * + * Time (eighths): | 01 | 02 | 03 | 04 | 05 | 06 | 07 | 08 | 09 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | + * + * Track 1: | B1 | B2 | B3 | B4 | B5 | B6 | + * Track 2: | B7 | B7 | B9 | B10| B11| B12| + * Track 3: | B13 | + * + * Lookup: | L1 | L2 | L3 | L4 | L5 | L6 | L7 | L8 | + * Active Beats: + * - L1 B1,B7,B13 + * - L2 B2,B7,B13 + * - L3 B3,B7,B13 + * - L4 B4,B7,B13 + * - L5 B5,B9,B13 + * - L6 B5,B10,B13 + * - L7 B6,B11,B13 + * - L8 B6,B12,B13 + * + * Then during playback we build out of this list {@link MidiTickLookupFindBeatResult} objects which are sepcific + * to the visible tracks displayed. This is required because if only Track 2 is displayed we cannot use the the + * Lookup L1 alone to determine the start and end of the beat cursor. In this case we will derive a + * MidiTickLookupFindBeatResult which holds for Time 01 the lookup L1 as start and L3 as end. This will be used + * both for the cursor and beat highlighting. */ export class MidiTickLookup { private _currentMasterBar: MasterBarTickLookup | null = null; @@ -71,164 +102,216 @@ export class MidiTickLookup { */ public readonly masterBars: MasterBarTickLookup[] = []; - /** - * Performs the neccessary finalization steps after all information was written. - * @internal - */ - public finish(): void { - let previous: MasterBarTickLookup | null = null; - let activeBeats: BeatTickLookup[] = []; - - for (let bar of this.masterBars) { - bar.finish(); - if (previous) { - previous.nextMasterBar = bar; - } - - for (const beat of bar.beats) { - // 1. calculate newly which beats are still active - const newActiveBeats: BeatTickLookup[] = []; - // TODO: only create new list if current position changed - for (let activeBeat of activeBeats) { - if (activeBeat.end > beat.start) { - newActiveBeats.push(activeBeat); - // 2. remember for current beat which active beats to highlight - beat.highlightBeat(activeBeat.beat); - // 3. ensure that active beat highlights current beat if they match the range - if (beat.start <= activeBeat.start) { - activeBeat.highlightBeat(beat.beat); - } - } - } - newActiveBeats.push(beat); - activeBeats = newActiveBeats; - } - previous = bar; - } - } - /** * Finds the currently played beat given a list of tracks and the current time. - * @param tracks The tracks in which to search the played beat for. + * @param trackLookup The tracks indices in which to search the played beat for. * @param tick The current time in midi ticks. * @returns The information about the current beat or null if no beat could be found. */ public findBeat( - tracks: Track[], + trackLookup: Set, tick: number, currentBeatHint: MidiTickLookupFindBeatResult | null = null ): MidiTickLookupFindBeatResult | null { - const trackLookup: Map = new Map(); - for (const track of tracks) { - trackLookup.set(track.index, true); - } - let result: MidiTickLookupFindBeatResult | null = null; if (currentBeatHint) { result = this.findBeatFast(trackLookup, currentBeatHint, tick); } if (!result) { - result = this.findBeatSlow(trackLookup, tick); + result = this.findBeatSlow(trackLookup, currentBeatHint, tick, false); } return result; } private findBeatFast( - trackLookup: Map, + trackLookup: Set, currentBeatHint: MidiTickLookupFindBeatResult, tick: number ): MidiTickLookupFindBeatResult | null { - const end = currentBeatHint.currentBeatLookup.start + currentBeatHint.tickDuration; - if (tick >= currentBeatHint.currentBeatLookup.start && tick < end) { - // still same beat? + // still within current lookup. + if (tick >= currentBeatHint.start && tick < currentBeatHint.end) { return currentBeatHint; - } else if ( - currentBeatHint.nextBeatLookup && - tick >= currentBeatHint.nextBeatLookup.start && - tick < currentBeatHint.nextBeatLookup.end + } + // already on the next beat? + else if ( + currentBeatHint.nextBeat && + tick >= currentBeatHint.nextBeat.start && + tick < currentBeatHint.nextBeat.end ) { - // maybe next beat? - return this.createResult(currentBeatHint.nextBeatLookup, trackLookup); + const next = currentBeatHint.nextBeat!; + // fill next in chain + this.fillNextBeat(next, trackLookup); + return next; } // likely a loop or manual seek, need to fallback to slow path return null; } - private findBeatSlow(trackLookup: Map, tick: number): MidiTickLookupFindBeatResult | null { + private fillNextBeat(current: MidiTickLookupFindBeatResult, trackLookup: Set) { + current.nextBeat = this.findBeatInMasterBar( + current.masterBar, + current.beatLookup.nextBeat, + current.end, trackLookup, false, true); + if (current.nextBeat == null) { + current.nextBeat = this.findBeatSlow(trackLookup, current, current.end, true); + } + + // if we have the next beat take the difference between the times as duration + if (current.nextBeat) { + current.tickDuration = current.nextBeat.start - current.start; + current.duration = MidiUtils.ticksToMillis(current.tickDuration, current.masterBar.tempo); + } + } + + private findBeatSlow(trackLookup: Set, currentBeatHint: MidiTickLookupFindBeatResult | null, tick: number, isNextSearch: boolean): MidiTickLookupFindBeatResult | null { // get all beats within the masterbar - const masterBar = this.findMasterBar(tick); + let masterBar: MasterBarTickLookup | null = null; + if (currentBeatHint != null) { + // same masterbar? + if (currentBeatHint.masterBar.start <= tick && + currentBeatHint.masterBar.end > tick) { + masterBar = currentBeatHint.masterBar; + } + // next masterbar + else if (currentBeatHint.masterBar.nextMasterBar && + currentBeatHint.masterBar.nextMasterBar.start <= tick && + currentBeatHint.masterBar.nextMasterBar.end > tick + ) { + masterBar = currentBeatHint.masterBar.nextMasterBar; + } + } + + // slowest lookup + if (!masterBar) { + masterBar = this.findMasterBar(tick); + } + + // no match if (!masterBar) { return null; } - let beat: BeatTickLookup | null = null; - let beats: BeatTickLookup[] = masterBar.beats; - for (let b: number = 0; b < beats.length; b++) { - // is the current beat played on the given tick? - let currentBeat: BeatTickLookup = beats[b]; - // skip non relevant beats - if (!trackLookup.has(currentBeat.beat.voice.bar.staff.track.index)) { - continue; + // scan through beats and find first one which has a beat visible + while (masterBar) { + if (masterBar.firstBeat) { + let beat = this.findBeatInMasterBar( + masterBar, + masterBar.firstBeat, + tick, + trackLookup, + true, + isNextSearch); + + if (beat) { + return beat; + } } - if (currentBeat.start <= tick && tick < currentBeat.end) { - // take the latest played beat we can find. (most right) - if (!beat || beat.start < currentBeat.start) { - beat = beats[b]; + + masterBar = masterBar.nextMasterBar; + } + + return null; + } + + /** + * Finds the beat at a given tick position within the known master bar. + * @param masterBar + * @param currentStartLookup + * @param tick + * @param visibleTracks + * @param fillNext + * @returns + */ + private findBeatInMasterBar( + masterBar: MasterBarTickLookup, + currentStartLookup: BeatTickLookup | null, + tick: number, + visibleTracks: Set, + fillNext: boolean, + isNextSeach: boolean): MidiTickLookupFindBeatResult | null { + if (!currentStartLookup) { + return null; + } + + let startBeatLookup: BeatTickLookup | null = null; + let startBeat: Beat | null = null; + + const relativeTick = tick - masterBar.start; + + while (currentStartLookup != null && startBeat == null) { + if (currentStartLookup.start <= relativeTick && relativeTick < currentStartLookup.end) { + startBeatLookup = currentStartLookup; + startBeat = currentStartLookup.getVisibleBeatAtStart(visibleTracks); + + // found the matching beat lookup but none of the beats are visible + // in this case scan further to the next lookup which has any visible beat + if (!startBeat) { + + if (isNextSeach) { + while (currentStartLookup != null) { + startBeat = currentStartLookup.getVisibleBeatAtStart(visibleTracks); + + if (startBeat) { + startBeatLookup = currentStartLookup; + break; + } + + currentStartLookup = currentStartLookup.nextBeat; + } + } else { + while (currentStartLookup != null) { + startBeat = currentStartLookup.getVisibleBeatAtStart(visibleTracks); + + if (startBeat) { + startBeatLookup = currentStartLookup; + break; + } + + currentStartLookup = currentStartLookup.previousBeat; + } + } + } - } else if (currentBeat.end > tick) { + } else if (currentStartLookup.end > relativeTick) { break; } + + currentStartLookup = currentStartLookup?.nextBeat ?? null; } - if (!beat) { + if (startBeat == null) { return null; } - return this.createResult(beat, trackLookup); - } + const result = this.createResult(masterBar, startBeatLookup!, startBeat, fillNext, visibleTracks); - private createResult(beat: BeatTickLookup, trackLookup: Map): MidiTickLookupFindBeatResult | null { - // search for next relevant beat in masterbar - const nextBeat = this.findNextBeat(beat, trackLookup); - const result = new MidiTickLookupFindBeatResult(); - result.currentBeatLookup = beat; - result.nextBeatLookup = nextBeat; - result.tickDuration = !nextBeat ? beat.end - beat.start : nextBeat.start - beat.start; - result.duration = MidiUtils.ticksToMillis(result.tickDuration, beat.masterBar.tempo) - result.beatsToHighlight = beat.beatsToHighlight; return result; } - private findNextBeat(beat: BeatTickLookup, trackLookup: Map): BeatTickLookup | null { - const masterBar = beat.masterBar; - let beats = masterBar.beats; - // search for next relevant beat in masterbar - let nextBeat: BeatTickLookup | null = null; - for (let b: number = beat.index + 1; b < beats.length; b++) { - const currentBeat: BeatTickLookup = beats[b]; - if (currentBeat.start > beat.start && trackLookup.has(currentBeat.beat.voice.bar.staff.track.index)) { - nextBeat = currentBeat; - break; - } - } + private createResult( + masterBar: MasterBarTickLookup, + beatLookup: BeatTickLookup, + beat: Beat, + fillNext: boolean, + visibleTracks: Set) { + const result = new MidiTickLookupFindBeatResult(masterBar); - // first relevant beat in next bar - if (!nextBeat && masterBar.nextMasterBar) { - beats = masterBar.nextMasterBar.beats; - for (let b: number = 0; b < beats.length; b++) { - const currentBeat: BeatTickLookup = beats[b]; - if (trackLookup.has(currentBeat.beat.voice.bar.staff.track.index)) { - nextBeat = currentBeat; - break; - } - } + result.beat = beat; + result.beatLookup = beatLookup; + + result.tickDuration = beatLookup!.end - beatLookup!.start; + + if (fillNext) { + this.fillNextBeat(result, visibleTracks); } - return nextBeat; + result.duration = MidiUtils.ticksToMillis(result.tickDuration, masterBar.tempo); + + return result; } private findMasterBar(tick: number): MasterBarTickLookup | null { @@ -284,7 +367,7 @@ export class MidiTickLookup { * @param beat The beat to find the time period for. * @returns The time in midi ticks at which the beat is played the first time or 0 if the beat is not contained */ - public getBeatStart(beat:Beat): number { + public getBeatStart(beat: Beat): number { if (!this.masterBarLookup.has(beat.voice.bar.index)) { return 0; } @@ -298,17 +381,16 @@ export class MidiTickLookup { */ public addMasterBar(masterBar: MasterBarTickLookup): void { this.masterBars.push(masterBar); + if (this._currentMasterBar) { + this._currentMasterBar.nextMasterBar = masterBar; + } this._currentMasterBar = masterBar; if (!this.masterBarLookup.has(masterBar.masterBar.index)) { this.masterBarLookup.set(masterBar.masterBar.index, masterBar); } } - /** - * Adds the given {@link BeatTickLookup} to the current {@link MidiTickLookup}. - * @param beat The lookup to add. - */ - public addBeat(beat: BeatTickLookup): void { - this._currentMasterBar?.addBeat(beat); + public addBeat(beat: Beat, start: number, duration: number): void { + this._currentMasterBar?.addBeat(beat, start, duration); } } diff --git a/test/audio/MidiTickLookup.test.ts b/test/audio/MidiTickLookup.test.ts index ca57c7ca2..b85e3fc04 100644 --- a/test/audio/MidiTickLookup.test.ts +++ b/test/audio/MidiTickLookup.test.ts @@ -1,12 +1,15 @@ -import { ScoreLoader } from '@src/importer'; -import { AlphaSynthMidiFileHandler, MidiFile, MidiFileGenerator, MidiTickLookup } from '@src/midi'; -import { Duration, Score } from '@src/model'; +import { ScoreLoader } from '@src/importer/ScoreLoader'; +import { ByteBuffer } from '@src/io/ByteBuffer'; +import { Logger } from '@src/Logger'; +import { AlphaSynthMidiFileHandler, MasterBarTickLookup, MidiFile, MidiFileGenerator, MidiTickLookup, MidiTickLookupFindBeatResult } from '@src/midi'; +import { MidiUtils } from '@src/midi/MidiUtils'; +import { Beat, Duration, MasterBar, Score } from '@src/model'; import { Settings } from '@src/Settings'; import { TestPlatform } from '@test/TestPlatform'; import { expect } from 'chai'; describe('MidiTickLookupTest', () => { - function buildLookup(score:Score, settings:Settings): MidiTickLookup { + function buildLookup(score: Score, settings: Settings): MidiTickLookup { const midiFile = new MidiFile(); const handler = new AlphaSynthMidiFileHandler(midiFile); const midiFileGenerator = new MidiFileGenerator(score, settings, handler); @@ -14,40 +17,553 @@ describe('MidiTickLookupTest', () => { return midiFileGenerator.tickLookup; } + it('variant-a', () => { + const lookup = new MidiTickLookup(); + + const masterBarLookup = new MasterBarTickLookup(); + masterBarLookup.masterBar = new MasterBar(); + masterBarLookup.start = 0; + masterBarLookup.tempo = 120; + masterBarLookup.end = masterBarLookup.start + masterBarLookup.masterBar.calculateDuration(); + lookup.addMasterBar(masterBarLookup); + + const nb = new Beat(); + lookup.addBeat(nb, 0, MidiUtils.QuarterTime); + + expect(masterBarLookup.firstBeat).to.be.ok; + expect(masterBarLookup.firstBeat!.start).to.equal(0); + expect(masterBarLookup.firstBeat!.end).to.equal(MidiUtils.QuarterTime); + expect(masterBarLookup.firstBeat!.highlightedBeats.length).to.equal(1); + expect(masterBarLookup.firstBeat!.highlightedBeats[0]).to.equal(nb); + }) + + function prepareVariantTest(): MidiTickLookup { + const lookup = new MidiTickLookup(); + + const masterBarLookup = new MasterBarTickLookup(); + masterBarLookup.masterBar = new MasterBar(); + masterBarLookup.start = 0; + masterBarLookup.tempo = 120; + masterBarLookup.end = masterBarLookup.start + masterBarLookup.masterBar.calculateDuration(); + lookup.addMasterBar(masterBarLookup); + + lookup.addBeat(new Beat(), MidiUtils.QuarterTime * 0, MidiUtils.QuarterTime); + lookup.addBeat(new Beat(), MidiUtils.QuarterTime * 1, MidiUtils.QuarterTime); + + expect(masterBarLookup.firstBeat).to.be.ok; + expect(masterBarLookup.firstBeat!.start).to.equal(0); + expect(masterBarLookup.firstBeat!.end).to.equal(MidiUtils.QuarterTime); + expect(masterBarLookup.firstBeat!.highlightedBeats.length).to.equal(1); + + expect(masterBarLookup.lastBeat).to.be.ok; + expect(masterBarLookup.lastBeat!.start).to.equal(MidiUtils.QuarterTime); + expect(masterBarLookup.lastBeat!.end).to.equal(2 * MidiUtils.QuarterTime); + expect(masterBarLookup.lastBeat!.highlightedBeats.length).to.equal(1); + + expect(masterBarLookup.firstBeat!.nextBeat).to.equal(masterBarLookup.lastBeat); + + return lookup; + } + + it('variant-b', () => { + const lookup = prepareVariantTest(); + const masterBar = lookup.masterBars[0]; + const l1 = masterBar.firstBeat!; + const l2 = masterBar.lastBeat!; + + const nb = new Beat(); + lookup.addBeat(nb, masterBar.lastBeat!.end, MidiUtils.QuarterTime); + + const n1 = masterBar.lastBeat!; + + expect(n1.highlightedBeats.length).to.equal(1); + expect(n1.highlightedBeats[0]).to.equal(nb); + expect(n1.start).to.equal(MidiUtils.QuarterTime * 2); + expect(n1.end).to.equal(MidiUtils.QuarterTime * 3); + + expect(l1).to.equal(masterBar.firstBeat!); + expect(l1.nextBeat).to.equal(l2); + expect(l2.nextBeat).to.equal(n1); + }) + + it('variant-c', () => { + const lookup = prepareVariantTest(); + const masterBar = lookup.masterBars[0]; + const l1 = masterBar.firstBeat!; + const l2 = masterBar.lastBeat!; + + const nb = new Beat(); + lookup.addBeat(nb, masterBar.lastBeat!.end + MidiUtils.QuarterTime, MidiUtils.QuarterTime); + + const n1 = masterBar.lastBeat!; + + expect(n1.highlightedBeats.length).to.equal(1); + expect(n1.highlightedBeats[0]).to.equal(nb); + expect(n1.start).to.equal(MidiUtils.QuarterTime * 2); + expect(n1.end).to.equal(MidiUtils.QuarterTime * 4); + + expect(l1).to.equal(masterBar.firstBeat!); + expect(l1.nextBeat).to.equal(l2); + expect(l2.nextBeat).to.equal(n1); + expect(n1).to.equal(masterBar.lastBeat!); + }) + + + it('variant-d', () => { + const lookup = prepareVariantTest(); + const masterBar = lookup.masterBars[0]; + const l1 = masterBar.firstBeat!; + const l2 = masterBar.lastBeat!; + + const nb = new Beat(); + lookup.addBeat(nb, l1.start - MidiUtils.QuarterTime, MidiUtils.QuarterTime); + + const n1 = masterBar.firstBeat!; + + expect(n1.highlightedBeats.length).to.equal(1); + expect(n1.highlightedBeats[0]).to.equal(nb); + expect(n1.start).to.equal(-MidiUtils.QuarterTime); + expect(n1.end).to.equal(0); + + expect(n1).to.equal(masterBar.firstBeat!); + expect(n1.nextBeat).to.equal(l1); + expect(l1.nextBeat).to.equal(l2); + expect(l2).to.equal(masterBar.lastBeat!); + }) + + it('variant-e', () => { + const lookup = prepareVariantTest(); + const masterBar = lookup.masterBars[0]; + const l1 = masterBar.firstBeat!; + const l2 = masterBar.lastBeat!; + + const nb = new Beat(); + lookup.addBeat(nb, l1.start - MidiUtils.QuarterTime * 2, MidiUtils.QuarterTime * 2); + + const n1 = masterBar.firstBeat!; + + expect(n1.highlightedBeats.length).to.equal(1); + expect(n1.highlightedBeats[0]).to.equal(nb); + expect(n1.start).to.equal(-MidiUtils.QuarterTime * 2); + expect(n1.end).to.equal(0); + + expect(n1).to.equal(masterBar.firstBeat!); + expect(n1.nextBeat).to.equal(l1); + expect(l1.nextBeat).to.equal(l2); + expect(l2).to.equal(masterBar.lastBeat!); + }) + + it('variant-f', () => { + const lookup = prepareVariantTest(); + const masterBar = lookup.masterBars[0]; + const l1 = masterBar.firstBeat!; + const l2 = masterBar.lastBeat!; + + const nb = new Beat(); + lookup.addBeat(nb, l1.start - MidiUtils.QuarterTime * 0.5, MidiUtils.QuarterTime); + + const n1 = masterBar.firstBeat!; + const n2 = n1.nextBeat!; + + expect(n1.highlightedBeats.length).to.equal(1); + expect(n1.highlightedBeats[0]).to.equal(nb); + expect(n1.start).to.equal(-MidiUtils.QuarterTime * 0.5); + expect(n1.end).to.equal(0); + + expect(n2.highlightedBeats.length).to.equal(2); + expect(n2.highlightedBeats[0]).to.equal(l1.highlightedBeats[0]); + expect(n2.highlightedBeats[1]).to.equal(nb); + expect(n2.start).to.equal(0); + expect(n2.end).to.equal(MidiUtils.QuarterTime * 0.5); + + expect(l1.highlightedBeats.length).to.equal(1); + expect(l1.start).to.equal(MidiUtils.QuarterTime * 0.5); + expect(l1.end).to.equal(MidiUtils.QuarterTime); + + expect(n1).to.equal(masterBar.firstBeat!); + expect(n1.nextBeat).to.equal(n2); + expect(n2.nextBeat).to.equal(l1); + expect(l1.nextBeat).to.equal(l2); + expect(l2).to.equal(masterBar.lastBeat!); + }) + + it('variant-g', () => { + const lookup = prepareVariantTest(); + const masterBar = lookup.masterBars[0]; + const l1 = masterBar.firstBeat!; + const l2 = masterBar.lastBeat!; + + const nb = new Beat(); + lookup.addBeat(nb, l1.start - MidiUtils.QuarterTime, MidiUtils.QuarterTime * 2); + + const n1 = masterBar.firstBeat!; + + expect(n1.highlightedBeats.length).to.equal(1); + expect(n1.highlightedBeats[0]).to.equal(nb); + expect(n1.start).to.equal(-MidiUtils.QuarterTime); + expect(n1.end).to.equal(0); + + expect(l1.highlightedBeats.length).to.equal(2); + expect(l1.highlightedBeats[1]).to.equal(nb); + expect(l1.start).to.equal(0); + expect(l1.end).to.equal(MidiUtils.QuarterTime); + + expect(n1).to.equal(masterBar.firstBeat!); + expect(n1.nextBeat).to.equal(l1); + expect(l1.nextBeat).to.equal(l2); + expect(l2).to.equal(masterBar.lastBeat!); + }) + + it('variant-h-variant-m', () => { + const lookup = prepareVariantTest(); + const masterBar = lookup.masterBars[0]; + const l1 = masterBar.firstBeat!; + const l2 = masterBar.lastBeat!; + + const nb = new Beat(); + lookup.addBeat(nb, l1.start - MidiUtils.QuarterTime, MidiUtils.QuarterTime * 2.5); + + const n1 = masterBar.firstBeat!; + const n2 = l1.nextBeat!; + + expect(n1.highlightedBeats.length).to.equal(1); + expect(n1.highlightedBeats[0]).to.equal(nb); + expect(n1.start).to.equal(-MidiUtils.QuarterTime); + expect(n1.end).to.equal(0); + + expect(l1.highlightedBeats.length).to.equal(2); + expect(l1.highlightedBeats[1]).to.equal(nb); + expect(l1.start).to.equal(0); + expect(l1.end).to.equal(MidiUtils.QuarterTime); + + expect(n2.highlightedBeats.length).to.equal(2); + expect(n2.highlightedBeats[0]).to.equal(l2.highlightedBeats[0]); + expect(n2.highlightedBeats[1]).to.equal(nb); + expect(n2.start).to.equal(MidiUtils.QuarterTime); + expect(n2.end).to.equal(MidiUtils.QuarterTime * 1.5); + + expect(l2.highlightedBeats.length).to.equal(1); + expect(l2.start).to.equal(MidiUtils.QuarterTime * 1.5); + expect(l2.end).to.equal(MidiUtils.QuarterTime * 2); + + expect(n1).to.equal(masterBar.firstBeat!); + expect(n1.nextBeat).to.equal(l1); + expect(l1.nextBeat).to.equal(n2); + expect(n2.nextBeat).to.equal(l2); + expect(l2).to.equal(masterBar.lastBeat!); + }) + + it('variant-i', () => { + const lookup = prepareVariantTest(); + const masterBar = lookup.masterBars[0]; + const l1 = masterBar.firstBeat!; + const l2 = masterBar.lastBeat!; + + const nb = new Beat(); + lookup.addBeat(nb, l1.start + MidiUtils.QuarterTime * 0.5, MidiUtils.QuarterTime * 0.5); + + const n1 = masterBar.firstBeat!; + + expect(n1.highlightedBeats.length).to.equal(1); + expect(n1.highlightedBeats[0]).to.equal(l1.highlightedBeats[0]); + expect(n1.start).to.equal(0); + expect(n1.end).to.equal(MidiUtils.QuarterTime * 0.5); + + expect(l1.highlightedBeats.length).to.equal(2); + expect(l1.highlightedBeats[1]).to.equal(nb); + expect(l1.start).to.equal(MidiUtils.QuarterTime * 0.5); + expect(l1.end).to.equal(MidiUtils.QuarterTime); + + expect(l2.highlightedBeats.length).to.equal(1); + expect(l2.start).to.equal(MidiUtils.QuarterTime * 1); + expect(l2.end).to.equal(MidiUtils.QuarterTime * 2); + + expect(n1).to.equal(masterBar.firstBeat!); + expect(n1.nextBeat).to.equal(l1); + expect(l1.nextBeat).to.equal(l2); + expect(l2).to.equal(masterBar.lastBeat!); + }) + + + it('variant-j', () => { + const lookup = prepareVariantTest(); + const masterBar = lookup.masterBars[0]; + const l1 = masterBar.firstBeat!; + const l2 = masterBar.lastBeat!; + + const nb = new Beat(); + lookup.addBeat(nb, l1.start + MidiUtils.QuarterTime * 0.25, MidiUtils.QuarterTime * 0.5); + + const n1 = masterBar.firstBeat!; + const n2 = n1.nextBeat!; + + expect(n1.highlightedBeats.length).to.equal(1); + expect(n1.highlightedBeats[0]).to.equal(l1.highlightedBeats[0]); + expect(n1.start).to.equal(0); + expect(n1.end).to.equal(MidiUtils.QuarterTime * 0.25); + + expect(n2.highlightedBeats.length).to.equal(2); + expect(n2.highlightedBeats[0]).to.equal(l1.highlightedBeats[0]); + expect(n2.highlightedBeats[1]).to.equal(nb); + expect(n2.start).to.equal(MidiUtils.QuarterTime * 0.25); + expect(n2.end).to.equal(MidiUtils.QuarterTime * 0.75); + + expect(l1.highlightedBeats.length).to.equal(1); + expect(l1.start).to.equal(MidiUtils.QuarterTime * 0.75); + expect(l1.end).to.equal(MidiUtils.QuarterTime); + + expect(l2.highlightedBeats.length).to.equal(1); + expect(l2.start).to.equal(MidiUtils.QuarterTime * 1); + expect(l2.end).to.equal(MidiUtils.QuarterTime * 2); + + expect(n1).to.equal(masterBar.firstBeat!); + expect(n1.nextBeat).to.equal(n2); + expect(n2.nextBeat).to.equal(l1); + expect(l1.nextBeat).to.equal(l2); + expect(l2).to.equal(masterBar.lastBeat!); + }) + + it('variant-k-variant-m', () => { + const lookup = prepareVariantTest(); + const masterBar = lookup.masterBars[0]; + const l1 = masterBar.firstBeat!; + const l2 = masterBar.lastBeat!; + + const nb = new Beat(); + lookup.addBeat(nb, l1.start + MidiUtils.QuarterTime * 0.25, MidiUtils.QuarterTime * 0.5); + + const n1 = masterBar.firstBeat!; + const n2 = n1.nextBeat!; + + expect(n1.highlightedBeats.length).to.equal(1); + expect(n1.highlightedBeats[0]).to.equal(l1.highlightedBeats[0]); + expect(n1.start).to.equal(0); + expect(n1.end).to.equal(MidiUtils.QuarterTime * 0.25); + + expect(n2.highlightedBeats.length).to.equal(2); + expect(n2.highlightedBeats[0]).to.equal(l1.highlightedBeats[0]); + expect(n2.highlightedBeats[1]).to.equal(nb); + expect(n2.start).to.equal(MidiUtils.QuarterTime * 0.25); + expect(n2.end).to.equal(MidiUtils.QuarterTime * 0.75); + + expect(l1.highlightedBeats.length).to.equal(1); + expect(l1.start).to.equal(MidiUtils.QuarterTime * 0.75); + expect(l1.end).to.equal(MidiUtils.QuarterTime); + + expect(l2.highlightedBeats.length).to.equal(1); + expect(l2.start).to.equal(MidiUtils.QuarterTime * 1); + expect(l2.end).to.equal(MidiUtils.QuarterTime * 2); + + expect(n1).to.equal(masterBar.firstBeat!); + expect(n1.nextBeat).to.equal(n2); + expect(n2.nextBeat).to.equal(l1); + expect(l1.nextBeat).to.equal(l2); + expect(l2).to.equal(masterBar.lastBeat!); + }) + + it('variant-l', () => { + const lookup = prepareVariantTest(); + const masterBar = lookup.masterBars[0]; + const l1 = masterBar.firstBeat!; + const l2 = masterBar.lastBeat!; + + const nb = new Beat(); + lookup.addBeat(nb, l1.start, MidiUtils.QuarterTime); + + expect(l1.highlightedBeats.length).to.equal(2); + expect(l1.highlightedBeats[1]).to.equal(nb); + + expect(l1).to.equal(masterBar.firstBeat!); + expect(l1.nextBeat).to.equal(l2); + expect(l2).to.equal(masterBar.lastBeat!); + }) + + it('variant-m', () => { + const lookup = prepareVariantTest(); + const masterBar = lookup.masterBars[0]; + const l1 = masterBar.firstBeat!; + const l2 = masterBar.lastBeat!; + + const nb = new Beat(); + lookup.addBeat(nb, l1.start, MidiUtils.QuarterTime * 0.5); + + const n1 = masterBar.firstBeat!; + + expect(n1.highlightedBeats.length).to.equal(2); + expect(n1.highlightedBeats[0]).to.equal(l1.highlightedBeats[0]); + expect(n1.highlightedBeats[1]).to.equal(nb); + expect(n1.start).to.equal(0); + expect(n1.end).to.equal(MidiUtils.QuarterTime * 0.5); + + expect(l1.highlightedBeats.length).to.equal(1); + expect(l1.start).to.equal(MidiUtils.QuarterTime * 0.5); + expect(l1.end).to.equal(MidiUtils.QuarterTime); + + expect(l2.highlightedBeats.length).to.equal(1); + expect(l2.start).to.equal(MidiUtils.QuarterTime * 1); + expect(l2.end).to.equal(MidiUtils.QuarterTime * 2); + + expect(n1).to.equal(masterBar.firstBeat!); + expect(n1.nextBeat).to.equal(l1); + expect(l1.nextBeat).to.equal(l2); + expect(l2).to.equal(masterBar.lastBeat!); + }) + + it('variant-h-variant-n-variant-b', () => { + const lookup = prepareVariantTest(); + const masterBar = lookup.masterBars[0]; + const l1 = masterBar.firstBeat!; + const l2 = masterBar.lastBeat!; + + const nb = new Beat(); + lookup.addBeat(nb, l1.start - MidiUtils.QuarterTime, MidiUtils.QuarterTime * 4); + + const n1 = masterBar.firstBeat!; + const n2 = l2.nextBeat!; + + expect(n1.highlightedBeats.length).to.equal(1); + expect(n1.highlightedBeats[0]).to.equal(nb); + expect(n1.start).to.equal(-MidiUtils.QuarterTime); + expect(n1.end).to.equal(0); + + expect(l1.highlightedBeats.length).to.equal(2); + expect(l1.highlightedBeats[1]).to.equal(nb); + expect(l1.start).to.equal(0); + expect(l1.end).to.equal(MidiUtils.QuarterTime); + + expect(l2.highlightedBeats.length).to.equal(2); + expect(l2.highlightedBeats[1]).to.equal(nb); + expect(l2.start).to.equal(MidiUtils.QuarterTime * 1); + expect(l2.end).to.equal(MidiUtils.QuarterTime * 2); + + expect(n2.highlightedBeats.length).to.equal(1); + expect(n2.highlightedBeats[0]).to.equal(nb); + expect(n2.start).to.equal(MidiUtils.QuarterTime * 2); + expect(n2.end).to.equal(MidiUtils.QuarterTime * 3); + + expect(n1).to.equal(masterBar.firstBeat!); + expect(n1.nextBeat).to.equal(l1); + expect(l1.nextBeat).to.equal(l2); + expect(l2.nextBeat).to.equal(n2); + expect(n2).to.equal(masterBar.lastBeat!); + }) + it('cursor-snapping', async () => { const buffer = await TestPlatform.loadFile('test-data/audio/cursor-snapping.gp'); const settings = new Settings(); const score = ScoreLoader.loadScoreFromBytes(buffer, settings); const lookup = buildLookup(score, settings); + const tracks = new Set([0]); + // initial lookup should detect correctly first rest on first voice // with the quarter rest on the second voice as next beat - const firstBeat = lookup.findBeat([score.tracks[0]], 0, null); - expect(firstBeat!.currentBeat.id).to.equal(score.tracks[0].staves[0].bars[0].voices[0].beats[0].id); - expect(firstBeat!.nextBeat!.id).to.equal(score.tracks[0].staves[0].bars[0].voices[1].beats[1].id); - expect(firstBeat!.currentBeat.duration).to.equal(Duration.Whole); - expect(firstBeat!.nextBeat!.duration).to.equal(Duration.Quarter); + const firstBeat = lookup.findBeat(tracks, 0, null); + + expect(firstBeat!.beat.id).to.equal(score.tracks[0].staves[0].bars[0].voices[0].beats[0].id); + expect(firstBeat!.nextBeat!.beat.id).to.equal(score.tracks[0].staves[0].bars[0].voices[1].beats[1].id); + expect(firstBeat!.beat.duration).to.equal(Duration.Whole); + expect(firstBeat!.nextBeat!.beat.duration).to.equal(Duration.Quarter); // Duration must only go to the next rest on the second voice despite the whole note - expect(firstBeat!.duration).to.equal(750); - expect(firstBeat!.tickDuration).to.equal(960); + expect(firstBeat!.duration).to.equal(750); + expect(firstBeat!.beatLookup.duration).to.equal(960); // Still playing first beat - const stillFirst = lookup.findBeat([score.tracks[0]], 400, firstBeat); - expect(stillFirst!.currentBeat.id).to.equal(score.tracks[0].staves[0].bars[0].voices[0].beats[0].id); - expect(stillFirst!.nextBeat!.id).to.equal(score.tracks[0].staves[0].bars[0].voices[1].beats[1].id); - expect(stillFirst!.currentBeat.duration).to.equal(Duration.Whole); - expect(stillFirst!.nextBeat!.duration).to.equal(Duration.Quarter); - expect(stillFirst!.duration).to.equal(750); - expect(stillFirst!.tickDuration).to.equal(960); + const stillFirst = lookup.findBeat(tracks, 400, firstBeat); + expect(stillFirst!.beat.id).to.equal(score.tracks[0].staves[0].bars[0].voices[0].beats[0].id); + expect(stillFirst!.nextBeat!.beat.id).to.equal(score.tracks[0].staves[0].bars[0].voices[1].beats[1].id); + expect(stillFirst!.beat.duration).to.equal(Duration.Whole); + expect(stillFirst!.nextBeat!.beat.duration).to.equal(Duration.Quarter); + expect(stillFirst!.duration).to.equal(750); + expect(stillFirst!.beatLookup.duration).to.equal(960); // Now we're past the second rest heading to the third - const secondBeat = lookup.findBeat([score.tracks[0]], 970 /* after first quarter */, stillFirst); - expect(secondBeat!.currentBeat.id).to.equal(score.tracks[0].staves[0].bars[0].voices[1].beats[1].id); - expect(secondBeat!.nextBeat!.id).to.equal(score.tracks[0].staves[0].bars[0].voices[1].beats[2].id); - expect(secondBeat!.currentBeat.duration).to.equal(Duration.Quarter); - expect(secondBeat!.nextBeat!.duration).to.equal(Duration.Quarter); - expect(secondBeat!.duration).to.equal(750); - expect(secondBeat!.tickDuration).to.equal(960); + const secondBeat = lookup.findBeat(tracks, 970 /* after first quarter */, stillFirst); + expect(secondBeat!.beat.id).to.equal(score.tracks[0].staves[0].bars[0].voices[1].beats[1].id); + expect(secondBeat!.nextBeat!.beat.id).to.equal(score.tracks[0].staves[0].bars[0].voices[1].beats[2].id); + expect(secondBeat!.beat.duration).to.equal(Duration.Quarter); + expect(secondBeat!.nextBeat!.beat.duration).to.equal(Duration.Quarter); + expect(secondBeat!.duration).to.equal(750); + expect(secondBeat!.beatLookup.duration).to.equal(960); + }); + + function nextBeatSearchTest(trackIndexes: number[], + durations: number[], + currentBeatFrets: number[], + nextBeatFrets: (number | null)[] + ) { + const buffer = ByteBuffer.fromString(` + \\tempo 67 + . + \\track "T01" + \\ts 1 4 1.1.8 2.1.8 | 6.1.8 7.1.8 | + \\track "T02" + 3.1.16 4.1.16 5.1.8 | 8.1.16 9.1.16 10.1.8 + `); + const settings = new Settings(); + const score = ScoreLoader.loadScoreFromBytes(buffer.getBuffer(), settings); + const lookup = buildLookup(score, settings); + + const tracks = new Set(trackIndexes); + + const ticks = [ + 0, 120, 240, 360, 480, 600, 720, 840, 960, + 1080, 1200, 1320, 1440, 1560, 1680, 1800 + ]; + + let currentLookup: MidiTickLookupFindBeatResult | null = null; + for (let i = 0; i < ticks.length; i++) { + currentLookup = lookup.findBeat(tracks, ticks[i], currentLookup); + + Logger.debug("Test", `Checking index ${i} with tick ${ticks[i]}`) + expect(currentLookup).to.be.ok; + expect(currentLookup!.beat.notes[0].fret).to.equal(currentBeatFrets[i]); + expect(currentLookup!.nextBeat?.beat?.notes?.[0]?.fret ?? null).to.equal(nextBeatFrets[i]); + expect(currentLookup!.tickDuration).to.equal(durations[i]); + + const cleanLookup = lookup.findBeat(tracks, ticks[i], null); + expect(cleanLookup).to.be.ok; + expect(cleanLookup!.beat.notes[0].fret).to.equal(currentBeatFrets[i]); + expect(cleanLookup!.nextBeat?.beat?.notes?.[0]?.fret ?? null).to.equal(nextBeatFrets[i]); + expect(cleanLookup!.tickDuration).to.equal(durations[i]); + } + } + + + it('next-beat-search-multi-track', () => { + nextBeatSearchTest( + [0, 1], + [ + 240, 240, 240, 240, 480, 480, 480, 480, + 240, 240, 240, 240, 480, 480, 480, 480 + ], + [ + 1, 1, 4, 4, 2, 2, 2, 2, + 6, 6, 9, 9, 7, 7, 7, 7 + ], + [ + 4, 4, 2, 2, 6, 6, 6, 6, + 9, 9, 7, 7, null, null, null, null + ] + ) + }); + + it('next-beat-search-track-1', () => { + nextBeatSearchTest( + [0], + [ + 480, 480, 480, 480, 480, 480, 480, 480, + 480, 480, 480, 480, 480, 480, 480, 480 + ], + [ + 1, 1, 1, 1, 2, 2, 2, 2, + 6, 6, 6, 6, 7, 7, 7, 7 + ], + [ + 2, 2, 2, 2, 6, 6, 6, 6, + 7, 7, 7, 7, null, null, null, null + ] + ) }); });