From fc9d349defdcfdf2973b19b8edf80a5e4ee0483c Mon Sep 17 00:00:00 2001 From: danielku15 Date: Thu, 8 Jan 2026 19:25:58 +0100 Subject: [PATCH 1/2] fix: Allow factional BPMs in musicxml (cherry picked from commit 8b150a98b17ef6c472c387b46ad5cfa94d9b7692) --- packages/alphatab/src/importer/MusicXmlImporter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/alphatab/src/importer/MusicXmlImporter.ts b/packages/alphatab/src/importer/MusicXmlImporter.ts index 400647a18..549da5689 100644 --- a/packages/alphatab/src/importer/MusicXmlImporter.ts +++ b/packages/alphatab/src/importer/MusicXmlImporter.ts @@ -2232,7 +2232,7 @@ export class MusicXmlImporter extends ScoreImporter { if (unit !== null && perMinute > 0) { const tempoAutomation: Automation = new Automation(); tempoAutomation.type = AutomationType.Tempo; - tempoAutomation.value = (perMinute * (unit / 4)) | 0; + tempoAutomation.value = perMinute * (unit / 4); tempoAutomation.ratioPosition = ratioPosition; if (!this._hasSameTempo(masterBar, tempoAutomation)) { From 5ea0408bf671c9cadda92934f550261d19ab411b Mon Sep 17 00:00:00 2001 From: danielku15 Date: Thu, 8 Jan 2026 19:26:19 +0100 Subject: [PATCH 2/2] fix: prevent endless loops due to invalid bpms (cherry picked from commit d5e4b3519cd921d5e486350f87cf2565a6ebe2f8) chore: clean import --- .../alphatab/src/synth/MidiFileSequencer.ts | 13 ++-- .../alphatab/test-data/audio/small-tempo.xml | 67 +++++++++++++++++++ .../alphatab/test/audio/AlphaSynth.test.ts | 67 ++++++++++++++++++- packages/alphatab/test/audio/TestOutput.ts | 16 ++++- .../test/importer/MusicXmlImporter.test.ts | 6 +- 5 files changed, 158 insertions(+), 11 deletions(-) create mode 100644 packages/alphatab/test-data/audio/small-tempo.xml diff --git a/packages/alphatab/src/synth/MidiFileSequencer.ts b/packages/alphatab/src/synth/MidiFileSequencer.ts index 4cf255579..1feac5b3e 100644 --- a/packages/alphatab/src/synth/MidiFileSequencer.ts +++ b/packages/alphatab/src/synth/MidiFileSequencer.ts @@ -268,7 +268,7 @@ export class MidiFileSequencer { if (mEvent.type === MidiEventType.TempoChange) { const meta: TempoChangeEvent = mEvent as TempoChangeEvent; - bpm = meta.beatsPerMinute; + bpm = MidiFileSequencer._sanitizeBpm(meta.beatsPerMinute); state.tempoChanges.push(new MidiFileSequencerTempoChange(bpm, absTick, absTime)); metronomeLengthInMillis = metronomeLengthInTicks * (60000.0 / (bpm * midiFile.division)); } else if (mEvent.type === MidiEventType.TimeSignature) { @@ -405,7 +405,7 @@ export class MidiFileSequencer { previousTick = 0; } else { const previousSyncPoint = syncPoints[i - 1]; - previousModifiedTempo = previousSyncPoint.syncBpm; + previousModifiedTempo = MidiFileSequencer._sanitizeBpm(previousSyncPoint.syncBpm); previousMillisecondOffset = previousSyncPoint.syncTime; previousTick = previousSyncPoint.synthTick; } @@ -436,7 +436,7 @@ export class MidiFileSequencer { syncPoint.syncBpm = previousModifiedTempo; } - bpm = state.tempoChanges[tempoChangeIndex].bpm; + bpm = MidiFileSequencer._sanitizeBpm(state.tempoChanges[tempoChangeIndex].bpm); tempoChangeIndex++; } @@ -462,11 +462,16 @@ export class MidiFileSequencer { this._updateCurrentTempo(state, timePosition); const lastTempoChange = state.tempoChanges[state.tempoChangeIndex]; const timeDiff = timePosition - lastTempoChange.time; - const ticks = (timeDiff / (60000.0 / (lastTempoChange.bpm * state.division))) | 0; + const ticks = + (timeDiff / (60000.0 / (MidiFileSequencer._sanitizeBpm(lastTempoChange.bpm) * state.division))) | 0; // we add 1 for possible rounding errors.(floating point issuses) return lastTempoChange.ticks + ticks + 1; } + private static _sanitizeBpm(bpm: number) { + return Math.max(bpm, 1); // prevent <0 bpms. Doesn't make sense and can cause endless loops + } + public currentUpdateCurrentTempo(timePosition: number) { this._updateCurrentTempo(this._mainState, timePosition * this.playbackSpeed); } diff --git a/packages/alphatab/test-data/audio/small-tempo.xml b/packages/alphatab/test-data/audio/small-tempo.xml new file mode 100644 index 000000000..0b0ef609f --- /dev/null +++ b/packages/alphatab/test-data/audio/small-tempo.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + Drums + + Drum Set + + + 10 + 1 + + + + + + + 4 + + 0 + + + + percussion + + + + + + quarter + 0.111 + + + + + + + 16 + 1 + + + + + + 16 + 1 + + + + + + 16 + 1 + + + + \ No newline at end of file diff --git a/packages/alphatab/test/audio/AlphaSynth.test.ts b/packages/alphatab/test/audio/AlphaSynth.test.ts index 36e3a9ef2..a75abe4de 100644 --- a/packages/alphatab/test/audio/AlphaSynth.test.ts +++ b/packages/alphatab/test/audio/AlphaSynth.test.ts @@ -2,7 +2,12 @@ import { ScoreLoader } from '@coderline/alphatab/importer/ScoreLoader'; import { ByteBuffer } from '@coderline/alphatab/io/ByteBuffer'; import { AlphaSynthMidiFileHandler } from '@coderline/alphatab/midi/AlphaSynthMidiFileHandler'; import { ControllerType } from '@coderline/alphatab/midi/ControllerType'; -import { type ControlChangeEvent, MidiEventType } from '@coderline/alphatab/midi/MidiEvent'; +import { + type ControlChangeEvent, + type MidiEvent, + MidiEventType, + TempoChangeEvent +} from '@coderline/alphatab/midi/MidiEvent'; import { MidiFile } from '@coderline/alphatab/midi/MidiFile'; import { MidiFileGenerator } from '@coderline/alphatab/midi/MidiFileGenerator'; import type { Score } from '@coderline/alphatab/model/Score'; @@ -12,9 +17,9 @@ import { AudioExportOptions } from '@coderline/alphatab/synth/IAudioExporter'; import { SynthConstants } from '@coderline/alphatab/synth/SynthConstants'; import { TinySoundFont } from '@coderline/alphatab/synth/synthesis/TinySoundFont'; import { VorbisFile } from '@coderline/alphatab/synth/vorbis/VorbisFile'; +import { assert, expect } from 'chai'; import { TestOutput } from 'test/audio/TestOutput'; import { TestPlatform } from 'test/TestPlatform'; -import { expect } from 'chai'; describe('AlphaSynthTests', () => { it('pcm-generation', async () => { @@ -332,4 +337,62 @@ describe('AlphaSynthTests', () => { expect(synth.channelGetPresetBank(2)).to.equal(4000); expect(synth.channelGetPresetBank(3)).to.equal(4000); }); + + async function testPlaythrough(midi: MidiFile) { + const testOutput = new TestOutput(false); + const synth = new AlphaSynth(testOutput, 500); + const soundFont = await TestPlatform.loadFile('test-data/audio/default.sf2'); + synth.loadSoundFont(soundFont, false); + synth.loadMidiFile(midi); + synth.play(); + let finished = false; + synth.finished.on(() => { + finished = true; + }); + + const start = Date.now(); + + while (!finished) { + const now = Date.now(); + if (now - start > 2000) { + assert.fail(`play did not complete after ${2000}ms`); + } + testOutput.next(); + } + } + + it('small-tempos', async () => { + const score = ScoreLoader.loadScoreFromBytes(await TestPlatform.loadFile('test-data/audio/small-tempo.xml')); + + expect(score.masterBars[0].tempoAutomations[0].value).to.equal(0.111); + + const midi = new MidiFile(); + const handler = new AlphaSynthMidiFileHandler(midi); + const generator = new MidiFileGenerator(score, null, handler); + generator.generate(); + + const tempoChange: MidiEvent[] = midi.events.filter(e => e instanceof TempoChangeEvent); + expect(tempoChange.length).to.equal(1); + expect((tempoChange[0] as TempoChangeEvent).beatsPerMinute).to.equal(0.111); + + await testPlaythrough(midi); + }); + + it('zero-tempo', async () => { + const score = ScoreLoader.loadScoreFromBytes(await TestPlatform.loadFile('test-data/audio/small-tempo.xml')); + + expect(score.masterBars[0].tempoAutomations[0].value).to.equal(0.111); + score.masterBars[0].tempoAutomations[0].value = 0; + + const midi = new MidiFile(); + const handler = new AlphaSynthMidiFileHandler(midi); + const generator = new MidiFileGenerator(score, null, handler); + generator.generate(); + + const tempoChange: MidiEvent[] = midi.events.filter(e => e instanceof TempoChangeEvent); + expect(tempoChange.length).to.equal(1); + expect((tempoChange[0] as TempoChangeEvent).beatsPerMinute).to.equal(0); + + await testPlaythrough(midi); + }); }); diff --git a/packages/alphatab/test/audio/TestOutput.ts b/packages/alphatab/test/audio/TestOutput.ts index b227c78f0..cddc79b58 100644 --- a/packages/alphatab/test/audio/TestOutput.ts +++ b/packages/alphatab/test/audio/TestOutput.ts @@ -1,5 +1,10 @@ import type { ISynthOutput, ISynthOutputDevice } from '@coderline/alphatab/synth/ISynthOutput'; -import { EventEmitter, type IEventEmitter, type IEventEmitterOfT, EventEmitterOfT } from '@coderline/alphatab/EventEmitter'; +import { + EventEmitter, + type IEventEmitter, + type IEventEmitterOfT, + EventEmitterOfT +} from '@coderline/alphatab/EventEmitter'; import { SynthConstants } from '@coderline/alphatab/synth/SynthConstants'; /** @@ -8,11 +13,16 @@ import { SynthConstants } from '@coderline/alphatab/synth/SynthConstants'; export class TestOutput implements ISynthOutput { public samples: Float32Array[] = []; public sampleCount: number = 0; + private _storeSamples: boolean; public get sampleRate(): number { return 44100; } + public constructor(storeSamples: boolean = true) { + this._storeSamples = storeSamples; + } + public open(_bufferTimeInMilliseconds: number): void { this.samples = []; (this.ready as EventEmitter).trigger(); @@ -35,7 +45,9 @@ export class TestOutput implements ISynthOutput { } public addSamples(f: Float32Array): void { - this.samples.push(f); + if (this._storeSamples) { + this.samples.push(f); + } this.sampleCount += f.length; (this.samplesPlayed as EventEmitterOfT).trigger(f.length / SynthConstants.AudioChannels); } diff --git a/packages/alphatab/test/importer/MusicXmlImporter.test.ts b/packages/alphatab/test/importer/MusicXmlImporter.test.ts index b007e246d..d5fb108f7 100644 --- a/packages/alphatab/test/importer/MusicXmlImporter.test.ts +++ b/packages/alphatab/test/importer/MusicXmlImporter.test.ts @@ -1,9 +1,9 @@ -import { MusicXmlImporterTestHelper } from 'test/importer/MusicXmlImporterTestHelper'; -import type { Score } from '@coderline/alphatab/model/Score'; import { BendType } from '@coderline/alphatab/model/BendType'; -import { expect } from 'chai'; import { JsonConverter } from '@coderline/alphatab/model/JsonConverter'; import { BarNumberDisplay } from '@coderline/alphatab/model/RenderStylesheet'; +import type { Score } from '@coderline/alphatab/model/Score'; +import { expect } from 'chai'; +import { MusicXmlImporterTestHelper } from 'test/importer/MusicXmlImporterTestHelper'; describe('MusicXmlImporterTests', () => { it('track-volume', async () => {