diff --git a/packages/alphatab/src/synth/AlphaSynth.ts b/packages/alphatab/src/synth/AlphaSynth.ts index 13a7e4e32..9fc66278b 100644 --- a/packages/alphatab/src/synth/AlphaSynth.ts +++ b/packages/alphatab/src/synth/AlphaSynth.ts @@ -118,6 +118,10 @@ export class AlphaSynthBase implements IAlphaSynth { this._countInVolume = value; } + protected get supportsCountIn(): boolean { + return true; + } + public get midiEventsPlayedFilter(): MidiEventType[] { return Array.from(this.midiEventsPlayedFilterSet); } @@ -321,7 +325,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..2099e96f1 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', () => { @@ -356,6 +358,89 @@ describe('SyncPointTests', () => { expect(events.map(e => `${e.currentTime},${e.originalTempo},${e.modifiedTempo}`)).toMatchSnapshot(); expect(testOutput.seekTimes).toMatchSnapshot(); }); + + /** + * 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(); + + // 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); + }); + + 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(); + + // 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); + }); + + /** + * 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); + }); + + /** + * 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); + }); }); /** @@ -450,7 +535,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;