From e7b39d579ad7d6bcd48c073139146cec88e61e8e Mon Sep 17 00:00:00 2001 From: leocaseiro Date: Wed, 7 Oct 2026 07:30:03 +1100 Subject: [PATCH 1/6] fix(player): skip count-in in backing track and external media modes instead of freezing With PlayerMode.EnabledBackingTrack or PlayerMode.EnabledExternalMedia and countInVolume > 0, pressing play froze the page (#2397). - MidiFileSequencer.fillMidiEventQueueToEndTime looped on the main state's time, but _fillMidiEventQueueLimited advances the current state. During the count-in the current state is the count-in state, so the loop never ended. It now loops on the state it advances. - AlphaSynthBase.play() started the count-in with a seek to 0, which BackingTrackPlayer forwards to the media, so starting at 30s rewound the media to the song start. These players have no synthesizer to play the count-in metronome, so they now skip the count-in through a new protected supportsCountIn hook (false in BackingTrackPlayer and ExternalMediaPlayer). Co-Authored-By: Claude Opus 5.5 --- packages/alphatab/src/synth/AlphaSynth.ts | 9 +- .../alphatab/src/synth/BackingTrackPlayer.ts | 5 ++ .../alphatab/src/synth/MidiFileSequencer.ts | 4 +- .../alphatab/test/audio/SyncPoint.test.ts | 82 +++++++++++++++++++ 4 files changed, 97 insertions(+), 3 deletions(-) diff --git a/packages/alphatab/src/synth/AlphaSynth.ts b/packages/alphatab/src/synth/AlphaSynth.ts index 13a7e4e32..b58f8653b 100644 --- a/packages/alphatab/src/synth/AlphaSynth.ts +++ b/packages/alphatab/src/synth/AlphaSynth.ts @@ -118,6 +118,13 @@ export class AlphaSynthBase implements IAlphaSynth { this._countInVolume = value; } + /** + * Whether this player can play a count-in before the playback starts. + */ + protected get supportsCountIn(): boolean { + return true; + } + public get midiEventsPlayedFilter(): MidiEventType[] { return Array.from(this.midiEventsPlayedFilterSet); } @@ -321,7 +328,7 @@ export class AlphaSynthBase implements IAlphaSynth { this._playInternal(); - if (this._countInVolume > 0) { + if (this._countInVolume > 0 && this.supportsCountIn) { Logger.debug('AlphaSynth', 'Starting countin'); this.sequencer.startCountIn(); this.synthesizer.setupMetronomeChannel(this.sequencer.metronomeChannel, this._countInVolume); diff --git a/packages/alphatab/src/synth/BackingTrackPlayer.ts b/packages/alphatab/src/synth/BackingTrackPlayer.ts index fb813fe8f..651dd21d2 100644 --- a/packages/alphatab/src/synth/BackingTrackPlayer.ts +++ b/packages/alphatab/src/synth/BackingTrackPlayer.ts @@ -177,6 +177,11 @@ export class BackingTrackPlayer extends AlphaSynthBase { // should never be called } + protected override get supportsCountIn(): boolean { + // not supported, the media plays as-is and there is no synthesizer for the count-in metronome + return false; + } + public override loadMidiFile(midi: MidiFile): void { if (!this.isSoundFontLoaded) { this.isSoundFontLoaded = true; diff --git a/packages/alphatab/src/synth/MidiFileSequencer.ts b/packages/alphatab/src/synth/MidiFileSequencer.ts index f3073d26a..a719bbc22 100644 --- a/packages/alphatab/src/synth/MidiFileSequencer.ts +++ b/packages/alphatab/src/synth/MidiFileSequencer.ts @@ -337,8 +337,8 @@ export class MidiFileSequencer { } public fillMidiEventQueueToEndTime(endTime: number) { - while (this._mainState.currentTime < endTime) { - if (this._fillMidiEventQueueLimited(endTime - this._mainState.currentTime)) { + while (this._currentState.currentTime < endTime) { + if (this._fillMidiEventQueueLimited(endTime - this._currentState.currentTime)) { this._synthesizer.synthesizeSilent(SynthConstants.MicroBufferSize); } } diff --git a/packages/alphatab/test/audio/SyncPoint.test.ts b/packages/alphatab/test/audio/SyncPoint.test.ts index aadf2bd4c..bd7cc6cc1 100644 --- a/packages/alphatab/test/audio/SyncPoint.test.ts +++ b/packages/alphatab/test/audio/SyncPoint.test.ts @@ -356,6 +356,88 @@ describe('SyncPointTests', () => { expect(events.map(e => `${e.currentTime},${e.originalTempo},${e.modifiedTempo}`)).toMatchSnapshot(); expect(testOutput.seekTimes).toMatchSnapshot(); }); + + /** + * See #2397: backing tracks and external media cannot play the count-in, + * enabling it must not freeze the playback. + */ + it('count-in-playback-backing-track', async () => { + const player = await prepareBackingTrackPlayer(); + player.countInVolume = 1; + player.play(); + + (player.output as TestBackingTrackOutput).playThroughSong(0, 5000, 50); + + expect(player.timePosition).toBeGreaterThan(0); + }); + + it('count-in-playback-external-media', async () => { + const player = await prepareExternalMediaPlayer(); + player.countInVolume = 1; + player.play(); + + ((player.output as IExternalMediaSynthOutput).handler as TestExternalMediaHandler).playThroughSong( + 0, + 5000, + 50 + ); + + expect(player.timePosition).toBeGreaterThan(0); + }); + + /** + * See #2397: starting the playback with count-in must not rewind the media to the song start. + */ + it('count-in-keeps-position-backing-track', async () => { + const player = await prepareBackingTrackPlayer(); + const testOutput = player.output as TestBackingTrackOutput; + player.timePosition = 30000; + player.countInVolume = 1; + const seekCount = testOutput.seekTimes.length; + + player.play(); + + expect(testOutput.seekTimes.length).toBe(seekCount); + expect(player.timePosition).toBe(30000); + }); + + it('count-in-keeps-position-external-media', async () => { + const player = await prepareExternalMediaPlayer(); + const testOutput = (player.output as IExternalMediaSynthOutput).handler as TestExternalMediaHandler; + player.timePosition = 30000; + player.countInVolume = 1; + const seekCount = testOutput.seekTimes.length; + + player.play(); + + expect(testOutput.seekTimes.length).toBe(seekCount); + expect(player.timePosition).toBe(30000); + }); + + /** + * See #2397: filling the queue up to a time position must advance the active + * (count-in) state, otherwise the loop never ends. + */ + it('fill-to-end-time-during-count-in', () => { + const score = ScoreLoader.loadAlphaTex(` + . + C4 * 4 + `); + + const midi = new MidiFile(); + const handler = new AlphaSynthMidiFileHandler(midi); + const generator = new MidiFileGenerator(score, new Settings(), handler); + generator.generate(); + + const sequencer = new MidiFileSequencer(new EmptyAudioSynthesizer()); + sequencer.loadMidi(midi); + sequencer.startCountIn(); + + sequencer.fillMidiEventQueueToEndTime(1000); + + expect(sequencer.isPlayingCountIn).toBe(true); + expect(sequencer.currentTime).toBe(1000); + }); }); /** From 10842293d7e001bf32e59732cb5d74dd357b6489 Mon Sep 17 00:00:00 2001 From: leocaseiro Date: Wed, 7 Oct 2026 20:58:46 +1100 Subject: [PATCH 2/6] test(player): cover count-in on the synthesizer player The supportsCountIn hook defaults to true, but every count-in test drives the media players where it is false, so flipping the default (or overriding it on AlphaSynth) kept the whole suite green. Play the real AlphaSynth with count-in from 1000ms and assert the reported position switches to the count-in's own clock (0). It uses only the public API: a test subclass of AlphaSynth would make the Kotlin transpiler emit the shipped class as open. Co-Authored-By: Claude Opus 5.5 --- .../alphatab/test/audio/SyncPoint.test.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/alphatab/test/audio/SyncPoint.test.ts b/packages/alphatab/test/audio/SyncPoint.test.ts index bd7cc6cc1..f38e6ac69 100644 --- a/packages/alphatab/test/audio/SyncPoint.test.ts +++ b/packages/alphatab/test/audio/SyncPoint.test.ts @@ -6,6 +6,7 @@ import { MidiFile } from '@coderline/alphatab/midi/MidiFile'; import { MidiFileGenerator } from '@coderline/alphatab/midi/MidiFileGenerator'; import type { BackingTrack } from '@coderline/alphatab/model/BackingTrack'; import { Settings } from '@coderline/alphatab/Settings'; +import { AlphaSynth } from '@coderline/alphatab/synth/AlphaSynth'; import { BackingTrackPlayer, type IBackingTrackSynthOutput } from '@coderline/alphatab/synth/BackingTrackPlayer'; import { ExternalMediaPlayer, @@ -19,6 +20,7 @@ import type { PositionChangedEventArgs } from '@coderline/alphatab/synth/Positio import type { Hydra } from '@coderline/alphatab/synth/soundfont/Hydra'; import type { SynthEvent } from '@coderline/alphatab/synth/synthesis/SynthEvent'; import { FlatMidiEventGenerator } from 'test/audio/FlatMidiEventGenerator'; +import { TestOutput } from 'test/audio/TestOutput'; import { TestPlatform } from 'test/TestPlatform'; describe('SyncPointTests', () => { @@ -438,6 +440,31 @@ describe('SyncPointTests', () => { expect(sequencer.isPlayingCountIn).toBe(true); expect(sequencer.currentTime).toBe(1000); }); + + /** + * See #2397: the synthesizer player still plays the count-in. + */ + it('count-in-playback-synthesizer', () => { + const score = ScoreLoader.loadAlphaTex(` + . + C4 * 4 + `); + + const midi = new MidiFile(); + const handler = new AlphaSynthMidiFileHandler(midi); + const generator = new MidiFileGenerator(score, new Settings(), handler); + generator.generate(); + + const synth = new AlphaSynth(new TestOutput(), 500); + synth.loadMidiFile(midi); + synth.timePosition = 1000; + synth.countInVolume = 1; + + expect(synth.play()).toBe(true); + + // the count-in plays from its own start, the song continues at 1000ms after it + expect(synth.timePosition).toBe(0); + }); }); /** From dc26aea97ce8a6e91928f86ff0968b20183bf357 Mon Sep 17 00:00:00 2001 From: leocaseiro Date: Wed, 7 Oct 2026 21:00:03 +1100 Subject: [PATCH 3/6] test(player): check playback starts when count-in is skipped in media modes The keeps-position tests ignored play()'s result and both test doubles discarded play(), so a skip path that never started the media, or a half-skip that still entered the count-in and rewound the media on the next time update, passed both tests. Count play() calls on the doubles, assert play() succeeds, the player is Playing and the media was started once, then feed one more media time update and assert there is no seek back and the position advances. Co-Authored-By: Claude Opus 5.5 --- .../alphatab/test/audio/SyncPoint.test.ts | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/alphatab/test/audio/SyncPoint.test.ts b/packages/alphatab/test/audio/SyncPoint.test.ts index f38e6ac69..61e520115 100644 --- a/packages/alphatab/test/audio/SyncPoint.test.ts +++ b/packages/alphatab/test/audio/SyncPoint.test.ts @@ -16,6 +16,7 @@ import { import type { IAudioSampleSynthesizer } from '@coderline/alphatab/synth/IAudioSampleSynthesizer'; import type { ISynthOutputDevice } from '@coderline/alphatab/synth/ISynthOutput'; import { MidiFileSequencer } from '@coderline/alphatab/synth/MidiFileSequencer'; +import { PlayerState } from '@coderline/alphatab/synth/PlayerState'; import type { PositionChangedEventArgs } from '@coderline/alphatab/synth/PositionChangedEventArgs'; import type { Hydra } from '@coderline/alphatab/synth/soundfont/Hydra'; import type { SynthEvent } from '@coderline/alphatab/synth/synthesis/SynthEvent'; @@ -396,11 +397,19 @@ describe('SyncPointTests', () => { player.timePosition = 30000; player.countInVolume = 1; const seekCount = testOutput.seekTimes.length; + const mediaTime = testOutput.seekTimes[testOutput.seekTimes.length - 1]; - player.play(); + expect(player.play()).toBe(true); + expect(player.state).toBe(PlayerState.Playing); + expect(testOutput.playCount).toBe(1); expect(testOutput.seekTimes.length).toBe(seekCount); expect(player.timePosition).toBe(30000); + + // the next media time update continues from there, no seek back + testOutput.simulateSeek(mediaTime + 100); + expect(testOutput.seekTimes.length).toBe(seekCount); + expect(player.timePosition).toBeGreaterThan(30000); }); it('count-in-keeps-position-external-media', async () => { @@ -409,11 +418,19 @@ describe('SyncPointTests', () => { player.timePosition = 30000; player.countInVolume = 1; const seekCount = testOutput.seekTimes.length; + const mediaTime = testOutput.seekTimes[testOutput.seekTimes.length - 1]; - player.play(); + expect(player.play()).toBe(true); + expect(player.state).toBe(PlayerState.Playing); + expect(testOutput.playCount).toBe(1); expect(testOutput.seekTimes.length).toBe(seekCount); expect(player.timePosition).toBe(30000); + + // the next media time update continues from there, no seek back + testOutput.simulateSeek(mediaTime + 100); + expect(testOutput.seekTimes.length).toBe(seekCount); + expect(player.timePosition).toBeGreaterThan(30000); }); /** @@ -499,7 +516,10 @@ class TestBackingTrackOutput implements IBackingTrackSynthOutput { public open(_bufferTimeInMilliseconds: number): void { (this.ready as EventEmitter).trigger(); } - public play(): void {} + public playCount: number = 0; + public play(): void { + this.playCount++; + } public destroy(): void {} public pause(): void {} public addSamples(_samples: Float32Array): void {} @@ -549,7 +569,10 @@ class TestExternalMediaHandler implements IExternalMediaHandler { seekTo(time: number): void { this.seekTimes.push(time); } - play(): void {} + public playCount: number = 0; + play(): void { + this.playCount++; + } pause(): void {} } From 9f3aba90efdcad650a705d2d449a8c249e6afdbe Mon Sep 17 00:00:00 2001 From: leocaseiro Date: Thu, 8 Oct 2026 01:35:29 +1100 Subject: [PATCH 4/6] test(player): fail instead of hanging when the sequencer fill loop stops advancing If fillMidiEventQueueToEndTime regresses to looping on the main state, fill-to-end-time-during-count-in spins forever: vitest's timeout cannot interrupt synchronous code, CI sets no job timeout, and the transpiled C# and Kotlin tests block the same way. The sequencer reads outSampleRate once per fill iteration, so the test synthesizer now counts the reads and throws after 100000 (the test needs 690), turning the hang into an immediate failure. Co-Authored-By: Claude Opus 5.5 --- packages/alphatab/test/audio/SyncPoint.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/alphatab/test/audio/SyncPoint.test.ts b/packages/alphatab/test/audio/SyncPoint.test.ts index 61e520115..1b160c359 100644 --- a/packages/alphatab/test/audio/SyncPoint.test.ts +++ b/packages/alphatab/test/audio/SyncPoint.test.ts @@ -582,7 +582,15 @@ class TestExternalMediaHandler implements IExternalMediaHandler { class EmptyAudioSynthesizer implements IAudioSampleSynthesizer { public masterVolume: number = 0; public metronomeVolume: number = 0; - public outSampleRate: number = 44100; + private _sampleRateReads: number = 0; + public get outSampleRate(): number { + // the sequencer reads this once per fill iteration: fail instead of hanging if it stops making progress + this._sampleRateReads++; + if (this._sampleRateReads > 100000) { + throw new Error('Sequencer is not making progress'); + } + return 44100; + } public currentTempo: number = 120; public timeSignatureNumerator: number = 4; public timeSignatureDenominator: number = 4; From 8d81bf8f1d8878019be4f1f62962209cdcdbea24 Mon Sep 17 00:00:00 2001 From: leocaseiro Date: Thu, 8 Oct 2026 02:58:50 +1100 Subject: [PATCH 5/6] test(player): trim the count-in tests to the core assertions Keep one compact check per behavior, like the surrounding SyncPoint tests: drop the two count-in playback smoke tests (they passed with either part of the fix reverted), check only that playback keeps its position and does not seek the media back, and revert the play() counters on the test outputs. The fill-loop guard stays, so a regression of the sequencer loop still fails instead of hanging the run. Co-Authored-By: Claude Opus 5.5 --- .../alphatab/test/audio/SyncPoint.test.ts | 63 +++---------------- 1 file changed, 8 insertions(+), 55 deletions(-) diff --git a/packages/alphatab/test/audio/SyncPoint.test.ts b/packages/alphatab/test/audio/SyncPoint.test.ts index 1b160c359..2099e96f1 100644 --- a/packages/alphatab/test/audio/SyncPoint.test.ts +++ b/packages/alphatab/test/audio/SyncPoint.test.ts @@ -16,7 +16,6 @@ import { import type { IAudioSampleSynthesizer } from '@coderline/alphatab/synth/IAudioSampleSynthesizer'; import type { ISynthOutputDevice } from '@coderline/alphatab/synth/ISynthOutput'; import { MidiFileSequencer } from '@coderline/alphatab/synth/MidiFileSequencer'; -import { PlayerState } from '@coderline/alphatab/synth/PlayerState'; import type { PositionChangedEventArgs } from '@coderline/alphatab/synth/PositionChangedEventArgs'; import type { Hydra } from '@coderline/alphatab/synth/soundfont/Hydra'; import type { SynthEvent } from '@coderline/alphatab/synth/synthesis/SynthEvent'; @@ -360,34 +359,6 @@ describe('SyncPointTests', () => { expect(testOutput.seekTimes).toMatchSnapshot(); }); - /** - * See #2397: backing tracks and external media cannot play the count-in, - * enabling it must not freeze the playback. - */ - it('count-in-playback-backing-track', async () => { - const player = await prepareBackingTrackPlayer(); - player.countInVolume = 1; - player.play(); - - (player.output as TestBackingTrackOutput).playThroughSong(0, 5000, 50); - - expect(player.timePosition).toBeGreaterThan(0); - }); - - it('count-in-playback-external-media', async () => { - const player = await prepareExternalMediaPlayer(); - player.countInVolume = 1; - player.play(); - - ((player.output as IExternalMediaSynthOutput).handler as TestExternalMediaHandler).playThroughSong( - 0, - 5000, - 50 - ); - - expect(player.timePosition).toBeGreaterThan(0); - }); - /** * See #2397: starting the playback with count-in must not rewind the media to the song start. */ @@ -397,17 +368,11 @@ describe('SyncPointTests', () => { player.timePosition = 30000; player.countInVolume = 1; const seekCount = testOutput.seekTimes.length; - const mediaTime = testOutput.seekTimes[testOutput.seekTimes.length - 1]; - - expect(player.play()).toBe(true); - expect(player.state).toBe(PlayerState.Playing); - expect(testOutput.playCount).toBe(1); - expect(testOutput.seekTimes.length).toBe(seekCount); - expect(player.timePosition).toBe(30000); + player.play(); - // the next media time update continues from there, no seek back - testOutput.simulateSeek(mediaTime + 100); + // no seek back to the song start, the next media update continues from 30s + testOutput.simulateSeek(testOutput.seekTimes[seekCount - 1] + 100); expect(testOutput.seekTimes.length).toBe(seekCount); expect(player.timePosition).toBeGreaterThan(30000); }); @@ -418,17 +383,11 @@ describe('SyncPointTests', () => { player.timePosition = 30000; player.countInVolume = 1; const seekCount = testOutput.seekTimes.length; - const mediaTime = testOutput.seekTimes[testOutput.seekTimes.length - 1]; - - expect(player.play()).toBe(true); - expect(player.state).toBe(PlayerState.Playing); - expect(testOutput.playCount).toBe(1); - expect(testOutput.seekTimes.length).toBe(seekCount); - expect(player.timePosition).toBe(30000); + player.play(); - // the next media time update continues from there, no seek back - testOutput.simulateSeek(mediaTime + 100); + // no seek back to the song start, the next media update continues from 30s + testOutput.simulateSeek(testOutput.seekTimes[seekCount - 1] + 100); expect(testOutput.seekTimes.length).toBe(seekCount); expect(player.timePosition).toBeGreaterThan(30000); }); @@ -516,10 +475,7 @@ class TestBackingTrackOutput implements IBackingTrackSynthOutput { public open(_bufferTimeInMilliseconds: number): void { (this.ready as EventEmitter).trigger(); } - public playCount: number = 0; - public play(): void { - this.playCount++; - } + public play(): void {} public destroy(): void {} public pause(): void {} public addSamples(_samples: Float32Array): void {} @@ -569,10 +525,7 @@ class TestExternalMediaHandler implements IExternalMediaHandler { seekTo(time: number): void { this.seekTimes.push(time); } - public playCount: number = 0; - play(): void { - this.playCount++; - } + play(): void {} pause(): void {} } From 5680d157af6ca889f3ad822189c1aeb9b2fb347c Mon Sep 17 00:00:00 2001 From: leocaseiro Date: Thu, 8 Oct 2026 03:19:54 +1100 Subject: [PATCH 6/6] chore(player): drop the doc comment on supportsCountIn Co-Authored-By: Claude Opus 5.5 --- packages/alphatab/src/synth/AlphaSynth.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/alphatab/src/synth/AlphaSynth.ts b/packages/alphatab/src/synth/AlphaSynth.ts index b58f8653b..9fc66278b 100644 --- a/packages/alphatab/src/synth/AlphaSynth.ts +++ b/packages/alphatab/src/synth/AlphaSynth.ts @@ -118,9 +118,6 @@ export class AlphaSynthBase implements IAlphaSynth { this._countInVolume = value; } - /** - * Whether this player can play a count-in before the playback starts. - */ protected get supportsCountIn(): boolean { return true; }