diff --git a/packages/alphatab/src/AlphaTabApiBase.ts b/packages/alphatab/src/AlphaTabApiBase.ts index fe1368669..9ca2e45d4 100644 --- a/packages/alphatab/src/AlphaTabApiBase.ts +++ b/packages/alphatab/src/AlphaTabApiBase.ts @@ -3121,28 +3121,29 @@ export class AlphaTabApiBase { if (this._selectionStart && this._tickCache) { // get the start and stop ticks (which consider properly repeats) const tickCache: MidiTickLookup = this._tickCache; - const realMasterBarStart: number = tickCache.getMasterBarStart( + const realStartMasterBarStart: number = tickCache.getMasterBarStart( this._selectionStart.beat.voice.bar.masterBar ); + const startBeatPlaybackRange = tickCache.getRelativeBeatPlaybackRange(this._selectionStart.beat); + const startBeatPlaybackStart = startBeatPlaybackRange?.startTick ?? this._selectionStart.beat.playbackStart; // move to selection start this._currentBeat = null; // reset current beat so it is updating the cursor if (this._player.state === PlayerState.Paused) { - this._cursorUpdateTick(this._tickCache.getBeatStart(this._selectionStart.beat), false, 1); + this._cursorUpdateTick(realStartMasterBarStart + startBeatPlaybackStart, false, 1); } - this.tickPosition = realMasterBarStart + this._selectionStart.beat.playbackStart; + this.tickPosition = realStartMasterBarStart + startBeatPlaybackStart; // set playback range if (this._selectionEnd && this._selectionStart.beat !== this._selectionEnd.beat) { - const realMasterBarEnd: number = tickCache.getMasterBarStart( + const realEndMasterBarStart: number = tickCache.getMasterBarStart( this._selectionEnd.beat.voice.bar.masterBar ); - + const endBeatPlaybackRange = tickCache.getRelativeBeatPlaybackRange(this._selectionEnd.beat); + const endBeatPlaybackEnd = + endBeatPlaybackRange?.endTick ?? + this._selectionEnd.beat.playbackStart + this._selectionEnd.beat.playbackDuration; const range = new PlaybackRange(); - range.startTick = realMasterBarStart + this._selectionStart.beat.playbackStart; - range.endTick = - realMasterBarEnd + - this._selectionEnd.beat.playbackStart + - this._selectionEnd.beat.playbackDuration - - 50; + range.startTick = realStartMasterBarStart + startBeatPlaybackStart; + range.endTick = realEndMasterBarStart + endBeatPlaybackEnd - 50; this.playbackRange = range; } else { this._selectionStart = undefined; @@ -3622,7 +3623,7 @@ export class AlphaTabApiBase { this._currentBeat = null; this._cursorUpdateTick(this._previousTick, false, 1, true, true); - if(this._selectionStart) { + if (this._selectionStart) { this.highlightPlaybackRange(this._selectionStart.beat, this._selectionEnd!.beat); } diff --git a/packages/alphatab/src/midi/MasterBarTickLookup.ts b/packages/alphatab/src/midi/MasterBarTickLookup.ts index 4c9ed688c..3c19f3117 100644 --- a/packages/alphatab/src/midi/MasterBarTickLookup.ts +++ b/packages/alphatab/src/midi/MasterBarTickLookup.ts @@ -248,7 +248,6 @@ export class MasterBarTickLookup { if (this.firstBeat == null) { const n1 = new BeatTickLookup(sliceStart, end); n1.highlightBeat(beat, beatPlaybackStart); - this._insertAfter(this.firstBeat, n1); } // Variant B diff --git a/packages/alphatab/src/midi/MidiTickLookup.ts b/packages/alphatab/src/midi/MidiTickLookup.ts index c8d7ba688..b8797d1d3 100644 --- a/packages/alphatab/src/midi/MidiTickLookup.ts +++ b/packages/alphatab/src/midi/MidiTickLookup.ts @@ -4,7 +4,7 @@ import { MasterBarTickLookup } from '@coderline/alphatab/midi/MasterBarTickLooku import { MidiUtils } from '@coderline/alphatab/midi/MidiUtils'; import type { Beat } from '@coderline/alphatab/model/Beat'; import type { MasterBar } from '@coderline/alphatab/model/MasterBar'; -import type { PlaybackRange } from '@coderline/alphatab/synth/PlaybackRange'; +import { PlaybackRange } from '@coderline/alphatab/synth/PlaybackRange'; /** * Describes how a cursor should be moving. @@ -188,6 +188,14 @@ export class MidiTickLookup { */ public readonly masterBarLookup: Map = new Map(); + /** + * A dictionary of all beat played. The index is the id to {@link Beat.id}. + * The value is the bar relative tick time at which the beat was registered during midi generation. + * This lookup only contains the first time a Beat is played. + * @internal + */ + public readonly beatLookup: Map = new Map(); + /** * A list of all {@link MasterBarTickLookup} sorted by time. */ @@ -671,11 +679,23 @@ export class MidiTickLookup { * @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 { - if (!this.masterBarLookup.has(beat.voice.bar.index)) { + if (!this.masterBarLookup.has(beat.voice.bar.index) || !this.beatLookup.has(beat.id)) { return 0; } - return this.masterBarLookup.get(beat.voice.bar.index)!.start + beat.playbackStart; + const mb = this.masterBarLookup.get(beat.voice.bar.index)!; + return mb.start + this.beatLookup.get(beat.id)!.startTick; + } + /** + * Gets the playback range in midi ticks for a given beat. + * @param beat The beat to find the time period for. + * @returns The relative playback range within the parent masterbar at which the beat start and ends playing + */ + public getRelativeBeatPlaybackRange(beat: Beat): PlaybackRange | undefined{ + if (!this.beatLookup.has(beat.id)) { + return undefined; + } + return this.beatLookup.get(beat.id)!; } /** @@ -695,6 +715,12 @@ export class MidiTickLookup { } public addBeat(beat: Beat, start: number, duration: number): void { + if (!this.beatLookup.has(beat.id)) { + const playbackRange = new PlaybackRange(); + playbackRange.startTick = start; + playbackRange.endTick = start + duration; + this.beatLookup.set(beat.id, playbackRange); + } const currentMasterBar = this._currentMasterBar; if (currentMasterBar) { // pre-beat grace notes at the start of the bar we also add the beat to the previous bar diff --git a/packages/alphatab/test/audio/MidiTickLookup.test.ts b/packages/alphatab/test/audio/MidiTickLookup.test.ts index 70e479ae7..4e151e9c6 100644 --- a/packages/alphatab/test/audio/MidiTickLookup.test.ts +++ b/packages/alphatab/test/audio/MidiTickLookup.test.ts @@ -25,6 +25,10 @@ import { Voice } from '@coderline/alphatab/model/Voice'; import { Settings } from '@coderline/alphatab/Settings'; import { TestPlatform } from 'test/TestPlatform'; import { PlaybackRange } from '@coderline/alphatab/synth/PlaybackRange'; +import { FlatMidiEvent, FlatMidiEventGenerator, FlatNoteEvent } from 'test/audio/FlatMidiEventGenerator'; +import { AlphaTabApiBase } from '@coderline/alphatab/AlphaTabApiBase'; +import { TestUiFacade } from 'test/visualTests/TestUiFacade'; +import { PlayerMode } from '@coderline/alphatab/PlayerSettings'; describe('MidiTickLookupTest', () => { function buildLookup(score: Score, settings: Settings): MidiTickLookup { @@ -729,9 +733,10 @@ describe('MidiTickLookupTest', () => { expect(actualIncrementalNextIds.join(','), 'nextBeatIds mismatch').toBe(nextBeatIds.join(',')); expect(actualIncrementalTickDurations.join(','), 'durations mismatch').toBe(durations.join(',')); if (expectedCursorModes) { - expect(expectedCursorModes.map(m => MidiTickLookupFindBeatResultCursorMode[m]).join(','), 'cursorModes mismatch').toBe( - actualCursorModes.map(m => MidiTickLookupFindBeatResultCursorMode[m]).join(',') - ); + expect( + expectedCursorModes.map(m => MidiTickLookupFindBeatResultCursorMode[m]).join(','), + 'cursorModes mismatch' + ).toBe(actualCursorModes.map(m => MidiTickLookupFindBeatResultCursorMode[m]).join(',')); } if (!skipClean) { @@ -1472,4 +1477,55 @@ describe('MidiTickLookupTest', () => { ); }); }); -}); \ No newline at end of file + + it('swing-click-lookup', async () => { + const settings = new Settings(); + settings.core.engine = 'svg'; + + const score = ScoreLoader.loadAlphaTex(`\\tf triplet8th C4.8 * 8`, settings); + const handler = new FlatMidiEventGenerator(); + const generator = new MidiFileGenerator(score, settings, handler); + generator.generate(); + + const noteEvents = handler.midiEvents.filter(e => e instanceof FlatNoteEvent); + expect(noteEvents.length).toBe(8); + + const beats = score.tracks[0].staves[0].bars[0].voices[0].beats; + + const facade = new TestUiFacade(); + facade.rootContainer.width = 1300; + + settings.player.playerMode = PlayerMode.EnabledSynthesizer; + const api = new AlphaTabApiBase(facade, settings); + + const promise = Promise.withResolvers(); + api.postRenderFinished.on(() => { + promise.resolve(score); + }); + api.error.on(e => promise.reject(e)); + api.renderScore(score, [0]); + + await promise.promise; + + for (let i = 0; i < beats.length; i++) { + const range = generator.tickLookup.getRelativeBeatPlaybackRange(beats[i]); + expect(range).not.toBeUndefined(); + + const noteStart = noteEvents[i].tick; + const noteEnd = noteEvents[i].tick + (noteEvents[i] as FlatNoteEvent).length; + + expect(range!.startTick).toBe(noteStart); + expect(range!.endTick).toBe(noteEnd); + + const playbackRangePadding = 50; // small offset to avoid overshoot, see applyPlaybackRangeFromHighlight + if (i < beats.length - 1) { + api.highlightPlaybackRange(beats[i], beats[i + 1]); + api.applyPlaybackRangeFromHighlight(); + + expect(api.playbackRange!.startTick).toBe(noteStart); + const nextNoteEnd = noteEvents[i + 1].tick + (noteEvents[i + 1] as FlatNoteEvent).length; + expect(api.playbackRange!.endTick).toBe(nextNoteEnd - playbackRangePadding); + } + } + }); +}); diff --git a/packages/alphatab/test/visualTests/TestUiFacade.ts b/packages/alphatab/test/visualTests/TestUiFacade.ts index 1b8ad03dd..c9c63fbaf 100644 --- a/packages/alphatab/test/visualTests/TestUiFacade.ts +++ b/packages/alphatab/test/visualTests/TestUiFacade.ts @@ -8,16 +8,18 @@ import { import { Settings } from '@coderline/alphatab/Settings'; import { ScoreLoader } from '@coderline/alphatab/importer/ScoreLoader'; import { Score } from '@coderline/alphatab/model/Score'; -import type { Cursors } from '@coderline/alphatab/platform/Cursors'; +import { Cursors } from '@coderline/alphatab/platform/Cursors'; import type { IContainer } from '@coderline/alphatab/platform/IContainer'; import type { IMouseEventArgs } from '@coderline/alphatab/platform/IMouseEventArgs'; import type { IUiFacade } from '@coderline/alphatab/platform/IUiFacade'; import type { IScoreRenderer } from '@coderline/alphatab/rendering/IScoreRenderer'; import type { RenderFinishedEventArgs } from '@coderline/alphatab/rendering/RenderFinishedEventArgs'; import { Bounds } from '@coderline/alphatab/rendering/utils/Bounds'; +import { AlphaSynth } from '@coderline/alphatab/synth/AlphaSynth'; import type { IAlphaSynth } from '@coderline/alphatab/synth/IAlphaSynth'; import type { IAudioExporterWorker } from '@coderline/alphatab/synth/IAudioExporter'; import { TestPlatform } from 'test/TestPlatform'; +import { TestOutput } from 'test/audio/TestOutput'; /** * @internal @@ -222,11 +224,18 @@ export class TestUiFacade implements IUiFacade { } public createWorkerPlayer(): IAlphaSynth | null { - throw new Error('Not supported'); + return new AlphaSynth(new TestOutput(), 500); } + private _cursors?: Cursors; public createCursors(): Cursors | null { - return null; + this._cursors = this._cursors ?? new Cursors( + new TestUiContainer(), + new TestUiContainer(), + new TestUiContainer(), + new TestUiContainer() + ); + return this._cursors; } public destroyCursors(): void {} @@ -240,7 +249,7 @@ export class TestUiFacade implements IUiFacade { public highlightElements(_groupId: string, _masterBarIndex: number): void {} public createSelectionElement(): IContainer | null { - return null; + return new TestUiContainer(); } public getScrollContainer(): IContainer {