From 6c7b549a48f6e1523903fa04b1b2d61d39bcb8ad Mon Sep 17 00:00:00 2001 From: danielku15 Date: Thu, 8 Oct 2026 12:49:54 +0200 Subject: [PATCH 1/6] fix(rendering): beam and swing pick-up bars as end of a full bar The beats of a pick-up bar are stored from tick 0, but musically they form the end of a full bar. Beaming and triplet feel interpreted the bar relative ticks as if the bar started on the downbeat, e.g. a 3/8 pick-up in 2/4 was beamed (C D) E instead of C (D E) and swung the offbeat eighth. MasterBar now caches the offset of the pick-up within the nominal meter (computed once in finish) which both features add to the bar relative position. Refs #2871 --- packages/alphatab/src/midi/MidiFileGenerator.ts | 3 ++- packages/alphatab/src/model/MasterBar.ts | 17 +++++++++++++++++ .../src/rendering/utils/BeamingHelper.ts | 6 ++++-- .../test/audio/MidiFileGenerator.test.ts | 11 +++++++++++ .../visualTests/features/SpecialNotes.test.ts | 14 ++++++++++++++ 5 files changed, 48 insertions(+), 3 deletions(-) diff --git a/packages/alphatab/src/midi/MidiFileGenerator.ts b/packages/alphatab/src/midi/MidiFileGenerator.ts index 7649d0b89c..d2fe2c7d95 100644 --- a/packages/alphatab/src/midi/MidiFileGenerator.ts +++ b/packages/alphatab/src/midi/MidiFileGenerator.ts @@ -853,8 +853,9 @@ export class MidiFileGenerator { // e.g. the eighth notes on a 4/4 time signature must start exactly on the following // times to get a triplet feel applied // 0 480 960 1440 1920 2400 2880 3360 + // (pick-up bars form the end of a full bar, the alignment is checked in the nominal meter) const pairSlot = interval * 2; - if (beatStart % pairSlot !== 0) { + if ((beatStart + beat.voice.bar.masterBar.anacrusisOffset) % pairSlot !== 0) { return null; } diff --git a/packages/alphatab/src/model/MasterBar.ts b/packages/alphatab/src/model/MasterBar.ts index 36097e1e92..9dc873911d 100644 --- a/packages/alphatab/src/model/MasterBar.ts +++ b/packages/alphatab/src/model/MasterBar.ts @@ -410,6 +410,18 @@ export class MasterBar { */ public isAnacrusis: boolean = false; + /** + * The position in the nominal meter (unit: midi ticks) at which the first tick of this bar sits. + * @remarks + * The beats of a pick-up bar are stored starting at tick 0 and the bar only plays for the duration of its content. + * Musically the content forms the end of a full bar: for a 3/8 pick-up in a 2/4 time signature, tick 0 is the + * offbeat of beat 1. Any metric interpretation of bar relative ticks (beaming, triplet feel, metronome) has to add + * this offset. For all other bars it is 0. Only available after data model finish. + * @json_ignore + * @internal + */ + public anacrusisOffset: number = 0; + /** * Gets a percentual scale for the size of the bars when displayed in a multi-track layout. */ @@ -529,5 +541,10 @@ export class MasterBar { if (this.beamingRules) { sharedDataBag.set('beamingRules', beamingRules); } + + // nominal duration is a plain calculation, the actual pick-up duration needs one pass over all staves. + this.anacrusisOffset = this.isAnacrusis + ? Math.max(0, this.calculateDuration(false) - this.calculateDuration()) + : 0; } } diff --git a/packages/alphatab/src/rendering/utils/BeamingHelper.ts b/packages/alphatab/src/rendering/utils/BeamingHelper.ts index 67a80e47f8..cf005fdbc7 100644 --- a/packages/alphatab/src/rendering/utils/BeamingHelper.ts +++ b/packages/alphatab/src/rendering/utils/BeamingHelper.ts @@ -285,8 +285,10 @@ export class BeamingHelper { } // check if they are on the same group as per rule definitions - const groupId1 = this._beamingRuleLookup.calculateGroupIndex(start1); - const groupId2 = this._beamingRuleLookup.calculateGroupIndex(start2); + // (pick-up bars form the end of a full bar, the rules apply to their position in the nominal meter) + const anacrusisOffset = m1.masterBar.anacrusisOffset; + const groupId1 = this._beamingRuleLookup.calculateGroupIndex(start1 + anacrusisOffset); + const groupId2 = this._beamingRuleLookup.calculateGroupIndex(start2 + anacrusisOffset); return groupId1 === groupId2; } diff --git a/packages/alphatab/test/audio/MidiFileGenerator.test.ts b/packages/alphatab/test/audio/MidiFileGenerator.test.ts index 78664f690e..020275e063 100644 --- a/packages/alphatab/test/audio/MidiFileGenerator.test.ts +++ b/packages/alphatab/test/audio/MidiFileGenerator.test.ts @@ -731,6 +731,17 @@ describe('MidiFileGeneratorTest', () => { ] ); }); + + it('anacrusis', () => { + // the pick-up forms the end of a full bar: the first eighth is an offbeat + testTripletFeel( + '\\ts 2 4 \\ac \\tf t8 3.2.8*3 | \\tf t8 3.2.8*4', + [0, 480, 960, 0, 480, 960, 1440], + [480, 480, 480, 480, 480, 480, 480], + [0, 480, 1120, 1440, 2080, 2400, 3040], + [480, 640, 320, 640, 320, 640, 320] + ); + }); }); it('beat-multi-bend', () => { diff --git a/packages/alphatab/test/visualTests/features/SpecialNotes.test.ts b/packages/alphatab/test/visualTests/features/SpecialNotes.test.ts index bffb743892..f56f8d9958 100644 --- a/packages/alphatab/test/visualTests/features/SpecialNotes.test.ts +++ b/packages/alphatab/test/visualTests/features/SpecialNotes.test.ts @@ -47,6 +47,20 @@ describe('SpecialNotesTests', () => { await VisualTestHelper.runVisualTest('special-notes/beaming-mode.gp'); }); + it('beaming-anacrusis', async () => { + // the pick-up forms the end of a full bar: first eighth is the offbeat of beat 1, the others form beat 2 + await VisualTestHelper.runVisualTestTex( + ` + \\ts 2 4 + \\ac C4.8 D4.8 E4.8 | + F4.4 G4.4 | + A4.8 B4.8 C5.8 D5.8 | + C4.2 + `, + 'test-data/visual-tests/special-notes/beaming-anacrusis.png' + ); + }); + it('full-bar-notes-rests', async () => { await VisualTestHelper.runVisualTestTex( ` From 98dab299f362c82a5086d6bd8eb1d9da81388c79 Mon Sep 17 00:00:00 2001 From: danielku15 Date: Thu, 8 Oct 2026 12:50:04 +0200 Subject: [PATCH 2/6] fix(midi): place automations in pick-up bars relative to the time signature Automation ratio positions are written relative to the full duration of the time signature (Guitar Pro stores 0.5 for an automation on the 3rd eighth note of a 3/8 pick-up in 2/4, the Gp/MusicXML/Capella importers follow this). The MIDI generation converted them using the shorter actual pick-up duration, playing tempo changes and sync points inside pick-ups too early (e.g. tick 720 instead of 960). The alphaTex importer wrote sustain pedal positions with the actual duration. All conversions now use one internal helper in ModelUtils, the convention is documented on Automation.ratioPosition. Refs #2871 --- .../alphatab/src/importer/AlphaTexImporter.ts | 3 +-- .../alphatab/src/midi/MidiFileGenerator.ts | 22 ++++++++----------- packages/alphatab/src/model/Automation.ts | 4 ++++ packages/alphatab/src/model/ModelUtils.ts | 18 +++++++++++++++ .../test/audio/MidiFileGenerator.test.ts | 18 +++++++++++++++ .../test/importer/AlphaTexImporter.test.ts | 7 ++++++ 6 files changed, 57 insertions(+), 15 deletions(-) diff --git a/packages/alphatab/src/importer/AlphaTexImporter.ts b/packages/alphatab/src/importer/AlphaTexImporter.ts index 547c7212ec..ce49854b13 100644 --- a/packages/alphatab/src/importer/AlphaTexImporter.ts +++ b/packages/alphatab/src/importer/AlphaTexImporter.ts @@ -300,8 +300,7 @@ export class AlphaTexImporter extends ScoreImporter implements IAlphaTexImporter } for (const [sustainPedal, beat] of this._state.sustainPedalToBeat) { if (sustainPedal.ratioPosition < 1) { - const duration = beat.voice.bar.masterBar.calculateDuration(); - sustainPedal.ratioPosition = beat.playbackStart / duration; + sustainPedal.ratioPosition = ModelUtils.tickToRatioPosition(beat.voice.bar.masterBar, beat.playbackStart); } } return this._state.score; diff --git a/packages/alphatab/src/midi/MidiFileGenerator.ts b/packages/alphatab/src/midi/MidiFileGenerator.ts index d2fe2c7d95..790dda9acb 100644 --- a/packages/alphatab/src/midi/MidiFileGenerator.ts +++ b/packages/alphatab/src/midi/MidiFileGenerator.ts @@ -17,6 +17,7 @@ import { BrushType } from '@coderline/alphatab/model/BrushType'; import { Duration } from '@coderline/alphatab/model/Duration'; import { GraceType } from '@coderline/alphatab/model/GraceType'; import type { MasterBar } from '@coderline/alphatab/model/MasterBar'; +import { ModelUtils } from '@coderline/alphatab/model/ModelUtils'; import type { Note } from '@coderline/alphatab/model/Note'; import type { PlaybackInformation } from '@coderline/alphatab/model/PlaybackInformation'; import type { Score } from '@coderline/alphatab/model/Score'; @@ -453,9 +454,8 @@ export class MidiFileGenerator { } // walk tempo changes and create points - const duration = bar.calculateDuration(); for (const change of bar.tempoAutomations) { - const absoluteTick = barStartTick + change.ratioPosition * duration; + const absoluteTick = barStartTick + ModelUtils.ratioPositionToTick(bar, change.ratioPosition); const tickOffset = absoluteTick - context.synthTick; if (tickOffset > 0) { context.synthTick = absoluteTick; @@ -481,7 +481,6 @@ export class MidiFileGenerator { private static _processBarTimeWithSyncPoints(bar: MasterBar, occurence: number, context: PlayThroughContext) { const barStartTick = context.synthTick; - const duration = bar.calculateDuration(); let tempoChangeIndex = 0; let tickOffset: number; @@ -491,7 +490,7 @@ export class MidiFileGenerator { continue; } - const syncPointTick = barStartTick + syncPoint.ratioPosition * duration; + const syncPointTick = barStartTick + ModelUtils.ratioPositionToTick(bar, syncPoint.ratioPosition); // first process all tempo changes until this sync point while ( @@ -499,7 +498,7 @@ export class MidiFileGenerator { bar.tempoAutomations[tempoChangeIndex].ratioPosition <= syncPoint.ratioPosition ) { const tempoChange = bar.tempoAutomations[tempoChangeIndex]; - const absoluteTick = barStartTick + tempoChange.ratioPosition * duration; + const absoluteTick = barStartTick + ModelUtils.ratioPositionToTick(bar, tempoChange.ratioPosition); tickOffset = absoluteTick - context.synthTick; if (tickOffset > 0) { @@ -543,7 +542,7 @@ export class MidiFileGenerator { // process remaining tempo changes after all sync points while (tempoChangeIndex < bar.tempoAutomations.length) { const tempoChange = bar.tempoAutomations[tempoChangeIndex]; - const absoluteTick = barStartTick + tempoChange.ratioPosition * duration; + const absoluteTick = barStartTick + ModelUtils.ratioPositionToTick(bar, tempoChange.ratioPosition); tickOffset = absoluteTick - context.synthTick; if (tickOffset > 0) { context.synthTick = absoluteTick; @@ -558,9 +557,8 @@ export class MidiFileGenerator { private static _processBarTimeNoSyncPoints(bar: MasterBar, context: PlayThroughContext) { // walk through the tempo changes const barStartTick = context.synthTick; - const duration = bar.calculateDuration(); for (const changes of bar.tempoAutomations) { - const absoluteTick = barStartTick + changes.ratioPosition * duration; + const absoluteTick = barStartTick + ModelUtils.ratioPositionToTick(bar, changes.ratioPosition); const tickOffset = absoluteTick - context.synthTick; if (tickOffset > 0) { context.synthTick = absoluteTick; @@ -606,7 +604,7 @@ export class MidiFileGenerator { } for (const automation of masterBar.tempoAutomations) { - const tick = currentTick + masterBarDuration * automation.ratioPosition; + const tick = currentTick + ModelUtils.ratioPositionToTick(masterBar, automation.ratioPosition); this._handler.addTempo(tick, automation.value); masterBarLookup.tempoChanges.push(new MasterBarTickLookupTempoChange(tick, automation.value)); } @@ -706,10 +704,8 @@ export class MidiFileGenerator { const remainingBarTempoAutomations = realBar.masterBar.tempoAutomations.slice(); let tempoOnBeatStart = tempoOnVoiceStart; - const barDuration = realBar.masterBar.calculateDuration(); - for (const b of voice.beats) { - const ratio = b.playbackStart / barDuration; + const ratio = ModelUtils.tickToRatioPosition(realBar.masterBar, b.playbackStart); while (remainingBarTempoAutomations.length > 0 && remainingBarTempoAutomations[0].ratioPosition <= ratio) { tempoOnBeatStart = remainingBarTempoAutomations.shift()!.value; @@ -2413,7 +2409,7 @@ export class MidiFileGenerator { tempo = masterBar.score.tempo; } - const positionRatio = beat.playbackStart / masterBar.calculateDuration(); + const positionRatio = ModelUtils.tickToRatioPosition(masterBar, beat.playbackStart); for (const automation of masterBar.tempoAutomations) { if (automation.ratioPosition <= positionRatio) { tempo = automation.value; diff --git a/packages/alphatab/src/model/Automation.ts b/packages/alphatab/src/model/Automation.ts index 2ba2a1ccab..bf5bb63340 100644 --- a/packages/alphatab/src/model/Automation.ts +++ b/packages/alphatab/src/model/Automation.ts @@ -104,6 +104,10 @@ export class Automation { /** * Gets or sets the relative position of of the automation. + * @remarks + * The position is relative to the full duration of the bar's time signature (0 = bar start, 1 = bar end). + * This also applies to pick-up bars (`MasterBar.isAnacrusis`) which are shorter than their time signature: + * an automation on the 3rd eighth note of a 3/8 pick-up in 2/4 has the position 0.5 (as written by Guitar Pro). */ public ratioPosition: number = 0; diff --git a/packages/alphatab/src/model/ModelUtils.ts b/packages/alphatab/src/model/ModelUtils.ts index df3d97f035..afba033fae 100644 --- a/packages/alphatab/src/model/ModelUtils.ts +++ b/packages/alphatab/src/model/ModelUtils.ts @@ -79,6 +79,24 @@ export class ModelUtils { return ModelUtils._durationIndices.get(duration)!; } + /** + * Converts a {@link Automation.ratioPosition} within the given bar into a midi tick offset relative to the bar start. + * @remarks + * The ratio is relative to the full duration of the time signature, also in pick-up bars which are shorter. + * This matches Guitar Pro which stores e.g. 0.5 for an automation on the 3rd eighth note of a 3/8 pick-up in 2/4. + */ + public static ratioPositionToTick(masterBar: MasterBar, ratioPosition: number): number { + return masterBar.calculateDuration(false) * ratioPosition; + } + + /** + * Converts a midi tick offset relative to the bar start into a {@link Automation.ratioPosition}. + * See {@link ratioPositionToTick} for the convention. + */ + public static tickToRatioPosition(masterBar: MasterBar, tick: number): number { + return tick / masterBar.calculateDuration(false); + } + public static keySignatureIsFlat(ks: number): boolean { return ks < 0; } diff --git a/packages/alphatab/test/audio/MidiFileGenerator.test.ts b/packages/alphatab/test/audio/MidiFileGenerator.test.ts index 020275e063..290e3edfda 100644 --- a/packages/alphatab/test/audio/MidiFileGenerator.test.ts +++ b/packages/alphatab/test/audio/MidiFileGenerator.test.ts @@ -1891,6 +1891,24 @@ describe('MidiFileGeneratorTest', () => { expect(actualTimers.join(',')).toBe(expectedTimers.join(',')); }); + it('anacrusis-tempo-automation', () => { + // automation positions are relative to the full time signature (like in Guitar Pro) + const score: Score = parseTex('\\ts 2 4 \\ac 3.3.8 3.3.8 3.3.8 { tempo 60 } | 3.3.2'); + expect(score.masterBars[0].tempoAutomations.map(a => a.ratioPosition).join(',')).toBe('0.5'); + + const handler: FlatMidiEventGenerator = new FlatMidiEventGenerator(); + const generator: MidiFileGenerator = new MidiFileGenerator(score, null, handler); + generator.generate(); + + const tempoChanges: string[] = []; + for (const e of handler.midiEvents) { + if (e instanceof FlatTempoEvent) { + tempoChanges.push(`${e.tick}:${e.tempo}`); + } + } + expect(tempoChanges.join(' ')).toBe('960:60'); + }); + it('transpose', () => { const score = parseTex(` \\track \\staff \\instrument piano diff --git a/packages/alphatab/test/importer/AlphaTexImporter.test.ts b/packages/alphatab/test/importer/AlphaTexImporter.test.ts index 037702f28d..deb82633ee 100644 --- a/packages/alphatab/test/importer/AlphaTexImporter.test.ts +++ b/packages/alphatab/test/importer/AlphaTexImporter.test.ts @@ -1005,6 +1005,13 @@ describe('AlphaTexImporterTest', () => { testExportRoundtrip(score); }); + it('anacrusis-sustain-pedal', () => { + // automation positions are relative to the full time signature (like in Guitar Pro) + const score = parseTex('\\ts 2 4 \\ac C4.8 D4.8 E4.8 {spd} | F4.4 {spu} G4.4'); + expect(score.tracks[0].staves[0].bars[0].sustainPedals.map(p => p.ratioPosition).join(',')).toBe('0.5'); + testExportRoundtrip(score); + }); + it('simple-anacrusis', () => { const tex: string = '\\ac 3.3 3.3 | 1.1 2.1 3.1 4.1'; const score = parseTex(tex); From 4a3541e2df87594afb03d112d07241e177f968a3 Mon Sep 17 00:00:00 2001 From: danielku15 Date: Thu, 8 Oct 2026 12:50:11 +0200 Subject: [PATCH 3/6] fix(midi): compute beat timers from the bar start time of the play-through Beat timers were calculated from a running time which every staff advanced by a whole bar. With multiple tracks or staves the timers accumulated (two tracks showed 0/4000/8000ms and 2000/6000/10000ms instead of 0/2000/4000ms). The bar end recalculation additionally compared a bar relative tempo automation tick with an absolute tick, breaking the timers of all bars following a mid-bar tempo change. All staves now start at the bar start time which the play-through already tracks correctly respecting tempo changes. Refs #2871 --- .../alphatab/src/midi/MidiFileGenerator.ts | 57 ++++--------------- .../test/audio/MidiFileGenerator.test.ts | 42 ++++++++++++++ 2 files changed, 52 insertions(+), 47 deletions(-) diff --git a/packages/alphatab/src/midi/MidiFileGenerator.ts b/packages/alphatab/src/midi/MidiFileGenerator.ts index 790dda9acb..e184a60259 100644 --- a/packages/alphatab/src/midi/MidiFileGenerator.ts +++ b/packages/alphatab/src/midi/MidiFileGenerator.ts @@ -164,11 +164,11 @@ export class MidiFileGenerator { this._detectTickShift(); } }, - (index, currentTick, currentTempo) => { + (index, currentTick, currentTempo, currentTime) => { for (const track of this._score.tracks) { for (const staff of track.staves) { if (index < staff.bars.length) { - this._generateBar(staff.bars[index], currentTick, currentTempo); + this._generateBar(staff.bars[index], currentTick, currentTempo, currentTime); } } } @@ -285,7 +285,7 @@ export class MidiFileGenerator { (_masterBar, _previousMasterBar, _currentTick, _currentTempo, _barOccurence) => { // no generation }, - (_barIndex, _currentTick, _currentTempo) => { + (_barIndex, _currentTick, _currentTempo, _currentTime) => { // no generation }, _endTick => { @@ -309,7 +309,7 @@ export class MidiFileGenerator { (_masterBar, _previousMasterBar, _currentTick, _currentTempo, _barOccurence) => { // no generation }, - (_barIndex, _currentTick, _currentTempo) => { + (_barIndex, _currentTick, _currentTempo, _currentTime) => { // no generation }, _endTick => { @@ -331,7 +331,7 @@ export class MidiFileGenerator { currentTempo: number, barOccurence: number ) => void, - generateTracks: (barIndex: number, currentTick: number, currentTempo: number) => void, + generateTracks: (barIndex: number, currentTick: number, currentTempo: number, currentTime: number) => void, finish: (endTick: number) => void ) { const controller: MidiPlaybackController = new MidiPlaybackController(score); @@ -359,7 +359,8 @@ export class MidiFileGenerator { const trackTempo = bar.tempoAutomations.length > 0 ? bar.tempoAutomations[0].value : playContext.currentTempo; - generateTracks(index, currentTick, trackTempo); + // the time position is tracked consistently on the play-through (respecting tempo changes) + generateTracks(index, currentTick, trackTempo, playContext.synthTime); playContext.synthTick = currentTick; MidiFileGenerator._processBarTime(bar, occurence, playContext); @@ -621,56 +622,18 @@ export class MidiFileGenerator { this.tickLookup.addMasterBar(masterBarLookup); } - private _generateBar(bar: Bar, barStartTick: number, tempoOnBarStart: number): void { + private _generateBar(bar: Bar, barStartTick: number, tempoOnBarStart: number, barStartTime: number): void { const playbackBar: Bar = this._getPlaybackBar(bar); - const barStartTime = this._currentTime; + // all staves and voices start at the same time position of the bar for (const v of playbackBar.voices) { this._currentTime = barStartTime; this._generateVoice(v, barStartTick, bar, tempoOnBarStart); } - // calculate the real bar end time (bars might be not full or overfilled) - const masterBar = playbackBar.masterBar; - const tickDuration = masterBar.calculateDuration(); - const tempoAutomations = masterBar.tempoAutomations.slice(); - if (tempoAutomations.length === 0) { - // fast path: no tempo automations -> simply apply whole duration - this._currentTime = barStartTime + MidiUtils.ticksToMillis(tickDuration, tempoOnBarStart); - } else { - // slow path: loop through slices and advance time - this._currentTime = barStartTime; - - let currentTick = barStartTick; - let currentTempo = tempoOnBarStart; - - const endTick = barStartTick + tickDuration; - - for (const automation of tempoAutomations) { - // calculate the tick difference to the next tempo automation - const automationTick = tickDuration * automation.ratioPosition; - const diff = automationTick - currentTick; - - // apply the time - if (diff > 0) { - this._currentTime += MidiUtils.ticksToMillis(diff, currentTempo); - } - - // apply automation advance time - currentTempo = automation.value; - currentTick += diff; - } - - // apply time until end - const remainingTick = endTick - currentTick; - if (remainingTick > 0) { - this._currentTime += MidiUtils.ticksToMillis(remainingTick, currentTempo); - } - } - // in case of simile marks where we repeat we register the empty beat for the whole bar if (playbackBar.id !== bar.id) { - this.tickLookup.addBeat(bar.voices[0].beats[0], 0, tickDuration); + this.tickLookup.addBeat(bar.voices[0].beats[0], 0, playbackBar.masterBar.calculateDuration()); //this.tickLookup.addBeat(beat, 0, audioDuration); } } diff --git a/packages/alphatab/test/audio/MidiFileGenerator.test.ts b/packages/alphatab/test/audio/MidiFileGenerator.test.ts index 290e3edfda..e957c144d7 100644 --- a/packages/alphatab/test/audio/MidiFileGenerator.test.ts +++ b/packages/alphatab/test/audio/MidiFileGenerator.test.ts @@ -1844,6 +1844,48 @@ describe('MidiFileGeneratorTest', () => { expect(actualTimers.join(',')).toBe(expectedTimers.join(',')); }); + it('beat-timer-multiple-tracks', () => { + const score: Score = parseTex(` + \\tempo 120 + . + \\track "T1" + 3.3.4 { timer } 3.3.4*3 | + 3.3.4 { timer } 3.3.4*3 | + 3.3.4 { timer } 3.3.4*3 + \\track "T2" + 3.3.4 { timer } 3.3.4*3 | + 3.3.4 { timer } 3.3.4*3 | + 3.3.4 { timer } 3.3.4*3 + `); + + const handler: FlatMidiEventGenerator = new FlatMidiEventGenerator(); + const generator: MidiFileGenerator = new MidiFileGenerator(score, null, handler); + generator.generate(); + + for (const track of score.tracks) { + const actualTimers = track.staves[0].bars.map(b => b.voices[0].beats[0].timer ?? -1); + expect(actualTimers.join(','), track.name).toBe('0,2000,4000'); + } + }); + + it('beat-timer-tempo-change-mid-bar', () => { + const score: Score = parseTex(` + \\tempo 120 + . + 3.3.4 { timer } 3.3.4*3 | + 3.3.4 { timer } 3.3.4 3.3.4 { tempo 60 } 3.3.4 | + 3.3.4 { timer } 3.3.4*3 + `); + + const handler: FlatMidiEventGenerator = new FlatMidiEventGenerator(); + const generator: MidiFileGenerator = new MidiFileGenerator(score, null, handler); + generator.generate(); + + const actualTimers = score.tracks[0].staves[0].bars.map(b => b.voices[0].beats[0].timer ?? -1); + // 2 beats at 120bpm (1000ms) + 2 beats at 60bpm (2000ms) + expect(actualTimers.join(',')).toBe('0,2000,5000'); + }); + it('beat-timer-tempo-changes', () => { const score: Score = parseTex(` \\tempo 120 From 334a23006e9d1e9a4f230ab2b49da18e404c3394 Mon Sep 17 00:00:00 2001 From: danielku15 Date: Thu, 8 Oct 2026 12:50:23 +0200 Subject: [PATCH 4/6] fix(player): align metronome and count-in with bars and pick-ups The metronome was generated by the sequencer on one continuous grid derived from the time signature events. A midi time signature cannot express the position of a pick-up within the meter, after a 3/8 pick-up in 2/4 every click fell on an offbeat for the whole song. The click counters were computed from the absolute tick and were wrong after time signature changes. The count-in always counted a single bar, letting pick-ups or mid-bar starts enter as if they were a downbeat. The MidiFileGenerator now emits the clicks bar by bar (respecting repeats and pick-ups) via the new IMidiFileHandler.addMetronome. They are not written to standard midi files. The sequencer uses these clicks and keeps the time signature based grid for midi files without clicks. The count-in counts one full bar and continues until the beat on which the playback starts (e.g. 3 clicks before a 3/8 pick-up in 2/4, matching Guitar Pro and MuseScore). Refs #2871 --- .../src/midi/AlphaSynthMidiFileHandler.ts | 9 +++ .../alphatab/src/midi/IMidiFileHandler.ts | 11 +++ packages/alphatab/src/midi/MidiEvent.ts | 5 +- .../alphatab/src/midi/MidiFileGenerator.ts | 37 ++++++++- .../alphatab/src/synth/MidiFileSequencer.ts | 76 +++++++++++++++++-- .../test/audio/FlatMidiEventGenerator.ts | 35 +++++++++ .../test/audio/MidiFileGenerator.test.ts | 38 ++++++++++ .../alphatab/test/audio/SyncPoint.test.ts | 50 ++++++++++++ 8 files changed, 251 insertions(+), 10 deletions(-) diff --git a/packages/alphatab/src/midi/AlphaSynthMidiFileHandler.ts b/packages/alphatab/src/midi/AlphaSynthMidiFileHandler.ts index 6f705e8706..f5a1ed5939 100644 --- a/packages/alphatab/src/midi/AlphaSynthMidiFileHandler.ts +++ b/packages/alphatab/src/midi/AlphaSynthMidiFileHandler.ts @@ -1,4 +1,5 @@ import { + AlphaTabMetronomeEvent, AlphaTabRestEvent, ControlChangeEvent, EndOfTrackEvent, @@ -70,6 +71,14 @@ export class AlphaSynthMidiFileHandler implements IMidiFileHandler { } } + public addMetronome(tick: number, counter: number, durationInTicks: number): void { + tick += this.tickShift; + if (!this._smf1Mode) { + // the duration in milliseconds is filled by the sequencer according to the tempo at this point + this._midiFile.addEvent(new AlphaTabMetronomeEvent(0, tick, counter, durationInTicks, 0)); + } + } + public addNote(track: number, start: number, length: number, key: number, velocity: number, channel: number): void { start += this.tickShift; this._midiFile.addEvent( diff --git a/packages/alphatab/src/midi/IMidiFileHandler.ts b/packages/alphatab/src/midi/IMidiFileHandler.ts index eb75f11e9d..b9f1036a99 100644 --- a/packages/alphatab/src/midi/IMidiFileHandler.ts +++ b/packages/alphatab/src/midi/IMidiFileHandler.ts @@ -94,4 +94,15 @@ export interface IMidiFileHandler { */ addTickShift(tickShift: number): void; + /** + * Adds a metronome click to the generated midi file. + * @param tick The midi ticks when the click should be happening. + * @param counter The index of the beat within the bar as per time signature (0 = first beat of the bar). + * @param durationInTicks The duration of one metronome beat in midi ticks. + * @remarks + * The clicks are generated bar by bar respecting repeats, the time signature and pick-up bars. Handlers + * which do not need a metronome (e.g. when writing standard midi files) can ignore this call. + */ + addMetronome(tick: number, counter: number, durationInTicks: number): void; + } diff --git a/packages/alphatab/src/midi/MidiEvent.ts b/packages/alphatab/src/midi/MidiEvent.ts index c3c1c5a5e7..5d649618c7 100644 --- a/packages/alphatab/src/midi/MidiEvent.ts +++ b/packages/alphatab/src/midi/MidiEvent.ts @@ -198,8 +198,9 @@ export abstract class AlphaTabSysExEvent extends MidiEvent { } /** - * Represents a metronome event. This event is emitted by the synthesizer only during playback and - * is typically not part of the midi file itself. + * Represents a metronome event. alphaTab places these events bar by bar into the midi files generated for playback + * (respecting repeats, time signatures and pick-up bars). They are not written into standard midi files (SMF1 mode). + * For midi files without these events the synthesizer generates them during playback according to the time signatures. * @public */ export class AlphaTabMetronomeEvent extends AlphaTabSysExEvent { diff --git a/packages/alphatab/src/midi/MidiFileGenerator.ts b/packages/alphatab/src/midi/MidiFileGenerator.ts index e184a60259..41c6ecc310 100644 --- a/packages/alphatab/src/midi/MidiFileGenerator.ts +++ b/packages/alphatab/src/midi/MidiFileGenerator.ts @@ -153,12 +153,19 @@ export class MidiFileGenerator { Logger.debug('Midi', 'Begin midi generation'); this.syncPoints = []; + let masterBarDuration = 0; MidiFileGenerator._playThroughSong( this._score, this.syncPoints, false, (bar, previousMasterBar, currentTick, currentTempo, occurence) => { - this._generateMasterBar(bar, previousMasterBar, currentTick, currentTempo, occurence); + masterBarDuration = this._generateMasterBar( + bar, + previousMasterBar, + currentTick, + currentTempo, + occurence + ); if (bar.index === 0 && occurence === 0) { // tickshift is added after initial track channel details this._detectTickShift(); @@ -172,6 +179,8 @@ export class MidiFileGenerator { } } } + // added after the bar contents to dispatch clicks after other events on the same tick + this._generateMetronome(this._score.masterBars[index], currentTick, masterBarDuration); }, endTick => { for (const track of this._score.tracks) { @@ -581,7 +590,7 @@ export class MidiFileGenerator { currentTick: number, currentTempo: number, _barOccurence: number - ): void { + ): number { // time signature if ( !previousMasterBar || @@ -620,6 +629,30 @@ export class MidiFileGenerator { masterBarLookup.start = currentTick; masterBarLookup.end = masterBarLookup.start + masterBarDuration; this.tickLookup.addMasterBar(masterBarLookup); + + return masterBarDuration; + } + + private _generateMetronome(masterBar: MasterBar, currentTick: number, masterBarDuration: number) { + // one click per beat of the time signature. pick-up bars form the end of a full bar, + // hence the clicks are placed at the beats of the nominal meter which fall into the bar. + const beatLength = MidiUtils.valueToTicks(masterBar.timeSignatureDenominator); + if (beatLength <= 0 || masterBar.timeSignatureNumerator <= 0) { + return; + } + + const anacrusisOffset = masterBar.anacrusisOffset; + let beatIndex = Math.ceil(anacrusisOffset / beatLength); + let clickTick = beatIndex * beatLength - anacrusisOffset; + while (clickTick < masterBarDuration) { + this._handler.addMetronome( + currentTick + clickTick, + beatIndex % masterBar.timeSignatureNumerator, + beatLength + ); + beatIndex++; + clickTick += beatLength; + } } private _generateBar(bar: Bar, barStartTick: number, tempoOnBarStart: number, barStartTime: number): void { diff --git a/packages/alphatab/src/synth/MidiFileSequencer.ts b/packages/alphatab/src/synth/MidiFileSequencer.ts index f3073d26ad..021399aa64 100644 --- a/packages/alphatab/src/synth/MidiFileSequencer.ts +++ b/packages/alphatab/src/synth/MidiFileSequencer.ts @@ -1,4 +1,5 @@ import { + type AlphaTabMetronomeEvent, MidiEventType, type NoteOnEvent, type ProgramChangeEvent, @@ -29,6 +30,18 @@ export class MidiFileSequencerTempoChange { } } +/** + * A metronome click of the main song used to align the count-in with the song. + * @internal + * @record + */ +interface MidiSequencerMetronomeClick { + time: number; + counter: number; + durationInTicks: number; + timeSignatureNumerator: number; +} + /** * @internal */ @@ -40,6 +53,7 @@ class MidiSequencerState { public firstTimeSignatureNumerator: number = 0; public firstTimeSignatureDenominator: number = 0; public synthData: SynthEvent[] = []; + public metronomeClicks: MidiSequencerMetronomeClick[] = []; public division: number = MidiUtils.QuarterTime; public eventIndex: number = 0; public currentTime: number = 0; @@ -238,16 +252,22 @@ export class MidiFileSequencer { let absTick: number = 0; let absTime: number = 0.0; + // midi files generated by alphaTab contain the metronome clicks (placed bar by bar), + // for other files we generate the clicks on a continuous grid according to the time signatures. + const events = midiFile.events; + const generateMetronome = !events.some(e => e.type === MidiEventType.AlphaTabMetronome); + let metronomeCount: number = 0; let metronomeLengthInTicks: number = 0; let metronomeLengthInMillis: number = 0; let metronomeTick: number = midiFile.tickShift; // shift metronome to content let metronomeTime: number = 0.0; + let timeSignatureNumerator: number = 4; let maxChannel = 0; let previousTick: number = 0; - for (const mEvent of midiFile.events) { + for (const mEvent of events) { const synthData: SynthEvent = new SynthEvent(state.synthData.length, mEvent); state.synthData.push(synthData); @@ -257,7 +277,7 @@ export class MidiFileSequencer { synthData.time = absTime; previousTick = mEvent.tick; - if (metronomeLengthInTicks > 0) { + if (generateMetronome && metronomeLengthInTicks > 0) { while (metronomeTick < absTick) { const metronome: SynthEvent = SynthEvent.newMetronomeEvent( state.synthData.length, @@ -281,6 +301,7 @@ export class MidiFileSequencer { } else if (mEvent.type === MidiEventType.TimeSignature) { const meta: TimeSignatureEvent = mEvent as TimeSignatureEvent; const timeSignatureDenominator: number = Math.pow(2, meta.denominatorIndex); + timeSignatureNumerator = meta.numerator; metronomeCount = meta.numerator; metronomeLengthInTicks = (state.division * (4.0 / timeSignatureDenominator)) | 0; metronomeLengthInMillis = metronomeLengthInTicks * (60000.0 / (bpm * midiFile.division)); @@ -288,6 +309,16 @@ export class MidiFileSequencer { state.firstTimeSignatureNumerator = meta.numerator; state.firstTimeSignatureDenominator = timeSignatureDenominator; } + } else if (mEvent.type === MidiEventType.AlphaTabMetronome) { + const metronome = mEvent as AlphaTabMetronomeEvent; + metronome.metronomeDurationInMilliseconds = + metronome.metronomeDurationInTicks * (60000.0 / (bpm * midiFile.division)); + state.metronomeClicks.push({ + time: absTime, + counter: metronome.metronomeNumerator, + durationInTicks: metronome.metronomeDurationInTicks, + timeSignatureNumerator + }); } else if (mEvent.type === MidiEventType.ProgramChange) { const programChange = mEvent as ProgramChangeEvent; const channel: number = programChange.channel; @@ -688,16 +719,35 @@ export class MidiFileSequencer { state.tempoChanges.push(new MidiFileSequencerTempoChange(bpm, 0, 0)); - const metronomeLengthInTicks: number = (state.division * (4.0 / timeSignatureDenominator)) | 0; - const metronomeLengthInMillis: number = metronomeLengthInTicks * (60000.0 / (bpm * this._mainState.division)); + const millisPerTick = 60000.0 / (bpm * this._mainState.division); + let metronomeLengthInTicks: number = (state.division * (4.0 / timeSignatureDenominator)) | 0; + let clickCount = timeSignatureNumerator; + let endTimeShift = 0; + + // align the count-in with the metronome of the song: we count one full bar and continue + // counting until the beat on which the playback starts (e.g. on pick-up bars or when starting mid-bar). + // the song then continues on the same grid. + const nextClick = this._findNextMainMetronomeClick(); + if (nextClick) { + const lengthInMillis = nextClick.durationInTicks * millisPerTick; + const distanceToClick = nextClick.time - this._mainState.currentTime; + if (distanceToClick < lengthInMillis) { + timeSignatureNumerator = nextClick.timeSignatureNumerator; + metronomeLengthInTicks = nextClick.durationInTicks; + clickCount = timeSignatureNumerator + nextClick.counter; + endTimeShift = -distanceToClick; + } + } + + const metronomeLengthInMillis: number = metronomeLengthInTicks * millisPerTick; let metronomeTick: number = 0; let metronomeTime: number = 0.0; - for (let i = 0; i < timeSignatureNumerator; i++) { + for (let i = 0; i < clickCount; i++) { const metronome: SynthEvent = SynthEvent.newMetronomeEvent( state.synthData.length, metronomeTick, - i, + i % timeSignatureNumerator, metronomeLengthInTicks, metronomeLengthInMillis ); @@ -707,6 +757,9 @@ export class MidiFileSequencer { metronomeTime += metronomeLengthInMillis; } + metronomeTime += endTimeShift; + metronomeTick += Math.round(endTimeShift / millisPerTick); + state.synthData.sort((a, b) => { if (a.time > b.time) { return 1; @@ -722,4 +775,15 @@ export class MidiFileSequencer { state.syncPointTempo = bpm; this._countInState = state; } + + private _findNextMainMetronomeClick(): MidiSequencerMetronomeClick | undefined { + // small tolerance for rounding differences between the seek position and the click times + const time = this._mainState.currentTime - 1; + for (const click of this._mainState.metronomeClicks) { + if (click.time >= time) { + return click; + } + } + return undefined; + } } diff --git a/packages/alphatab/test/audio/FlatMidiEventGenerator.ts b/packages/alphatab/test/audio/FlatMidiEventGenerator.ts index 728bf0ecad..51be981e9a 100644 --- a/packages/alphatab/test/audio/FlatMidiEventGenerator.ts +++ b/packages/alphatab/test/audio/FlatMidiEventGenerator.ts @@ -6,8 +6,13 @@ import type { IMidiFileHandler } from '@coderline/alphatab/midi/IMidiFileHandler */ export class FlatMidiEventGenerator implements IMidiFileHandler { public midiEvents: FlatMidiEvent[] = []; + public metronomeEvents: FlatMetronomeEvent[] = []; public tickShift = 0; + public addMetronome(tick: number, counter: number, durationInTicks: number): void { + this.metronomeEvents.push(new FlatMetronomeEvent(tick, counter, durationInTicks)); + } + public addTickShift(tickShift: number): void { this.tickShift = tickShift; } @@ -122,6 +127,36 @@ export class FlatTempoEvent extends FlatMidiEvent { } } +/** + * @internal + */ +export class FlatMetronomeEvent extends FlatMidiEvent { + public counter: number = 0; + public durationInTicks: number = 0; + + public constructor(tick: number, counter: number, durationInTicks: number) { + super(tick); + this.counter = counter; + this.durationInTicks = durationInTicks; + } + + public override toString(): string { + return `Metronome: ${super.toString()} Counter[${this.counter}] Duration[${this.durationInTicks}]`; + } + + public override equals(obj: unknown): boolean { + if (!super.equals(obj)) { + return false; + } + + if (obj instanceof FlatMetronomeEvent) { + return this.counter === obj.counter && this.durationInTicks === obj.durationInTicks; + } + + return false; + } +} + /** * @internal */ diff --git a/packages/alphatab/test/audio/MidiFileGenerator.test.ts b/packages/alphatab/test/audio/MidiFileGenerator.test.ts index e957c144d7..7291cbb718 100644 --- a/packages/alphatab/test/audio/MidiFileGenerator.test.ts +++ b/packages/alphatab/test/audio/MidiFileGenerator.test.ts @@ -1933,6 +1933,44 @@ describe('MidiFileGeneratorTest', () => { expect(actualTimers.join(',')).toBe(expectedTimers.join(',')); }); + describe('metronome', () => { + function testMetronome(tex: string, expectedClicks: string) { + const score: Score = parseTex(tex); + const handler: FlatMidiEventGenerator = new FlatMidiEventGenerator(); + const generator: MidiFileGenerator = new MidiFileGenerator(score, null, handler); + generator.generate(); + const actualClicks = handler.metronomeEvents.map(e => `${e.tick}:${e.counter}`).join(' '); + expect(actualClicks).toBe(expectedClicks); + } + + it('regular', () => { + testMetronome('\\ts 3 4 3.3.4*3 | 3.3.4*3', '0:0 960:1 1920:2 2880:0 3840:1 4800:2'); + }); + + it('time-signature-change', () => { + testMetronome( + '\\ts 7 8 3.3.8*7 | \\ts 4 4 3.3.1', + '0:0 480:1 960:2 1440:3 1920:4 2400:5 2880:6 3360:0 4320:1 5280:2 6240:3' + ); + }); + + it('anacrusis', () => { + // 3/8 pick-up in 2/4: the pick-up starts on the offbeat of beat 1 + testMetronome('\\ts 2 4 \\ac 3.3.8*3 | 3.3.2 | 3.3.2', '480:1 1440:0 2400:1 3360:0 4320:1'); + }); + + it('anacrusis-full-beats', () => { + testMetronome('\\ts 4 4 \\ac 3.3.4 | 3.3.1', '0:3 960:0 1920:1 2880:2 3840:3'); + }); + + it('anacrusis-repeat', () => { + testMetronome( + '\\ts 2 4 \\ac 3.3.8*3 | \\ro 3.3.2 | \\rc 2 3.3.2', + '480:1 1440:0 2400:1 3360:0 4320:1 5280:0 6240:1 7200:0 8160:1' + ); + }); + }); + it('anacrusis-tempo-automation', () => { // automation positions are relative to the full time signature (like in Guitar Pro) const score: Score = parseTex('\\ts 2 4 \\ac 3.3.8 3.3.8 3.3.8 { tempo 60 } | 3.3.2'); diff --git a/packages/alphatab/test/audio/SyncPoint.test.ts b/packages/alphatab/test/audio/SyncPoint.test.ts index aadf2bd4c2..8526753d61 100644 --- a/packages/alphatab/test/audio/SyncPoint.test.ts +++ b/packages/alphatab/test/audio/SyncPoint.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { type IEventEmitterOfT, type IEventEmitter, EventEmitterOfT, EventEmitter } from '@coderline/alphatab/EventEmitter'; import { ScoreLoader } from '@coderline/alphatab/importer/ScoreLoader'; import { AlphaSynthMidiFileHandler } from '@coderline/alphatab/midi/AlphaSynthMidiFileHandler'; +import type { AlphaTabMetronomeEvent } from '@coderline/alphatab/midi/MidiEvent'; import { MidiFile } from '@coderline/alphatab/midi/MidiFile'; import { MidiFileGenerator } from '@coderline/alphatab/midi/MidiFileGenerator'; import type { BackingTrack } from '@coderline/alphatab/model/BackingTrack'; @@ -358,6 +359,45 @@ describe('SyncPointTests', () => { }); }); +describe('MidiFileSequencerCountInTests', () => { + function testCountIn(tex: string, startTime: number, expectedClicks: string, expectedEndTime: number) { + const score = ScoreLoader.loadAlphaTex(tex); + const midi = new MidiFile(); + new MidiFileGenerator(score, new Settings(), new AlphaSynthMidiFileHandler(midi)).generate(); + + const synthesizer = new RecordingAudioSynthesizer(); + const sequencer = new MidiFileSequencer(synthesizer); + sequencer.loadMidi(midi); + sequencer.mainSeek(startTime); + + synthesizer.events = []; + sequencer.startCountIn(); + while (!sequencer.isFinished) { + sequencer.fillMidiEventQueue(); + } + + const actualClicks = synthesizer.events + .filter(e => e.isMetronome) + .map(e => `${Math.round(e.time)}:${(e.event as AlphaTabMetronomeEvent).metronomeNumerator}`); + expect(actualClicks.join(' ')).toBe(expectedClicks); + expect(Math.round(sequencer.currentEndTime)).toBe(expectedEndTime); + } + + it('bar-start', () => { + testCountIn('\\tempo 120 . \\ts 4 4 C4.4*4 | C4.1', 0, '0:0 500:1 1000:2 1500:3', 2000); + }); + + it('anacrusis', () => { + // one full bar, then the counting continues until the pick-up enters on the offbeat of beat 1 + testCountIn('\\tempo 120 . \\ts 2 4 \\ac C4.8*3 | C4.2', 0, '0:0 500:1 1000:0', 1250); + }); + + it('mid-bar', () => { + // start on beat 3: one full bar, then beats 1 and 2 + testCountIn('\\tempo 120 . \\ts 4 4 C4.4*4 | C4.1', 1000, '0:0 500:1 1000:2 1500:3 2000:0 2500:1', 3000); + }); +}); + /** * @internal */ @@ -483,3 +523,13 @@ class EmptyAudioSynthesizer implements IAudioSampleSynthesizer { return true; } } + +/** + * @internal + */ +class RecordingAudioSynthesizer extends EmptyAudioSynthesizer { + public events: SynthEvent[] = []; + public override dispatchEvent(synthEvent: SynthEvent): void { + this.events.push(synthEvent); + } +} From 02b762ff00c237fa7ace18f411e6bc61ae2cc92b Mon Sep 17 00:00:00 2001 From: danielku15 Date: Thu, 8 Oct 2026 13:01:15 +0200 Subject: [PATCH 5/6] fix(midi): merge track events of multi-track midi files MidiFile.events called itself recursively for files in the multi-track format, overflowing the stack (e.g. when loading such a file into the player) and would have returned an empty list. The already sorted tracks are now merged, which keeps the order of events on the same tick on all platforms (the sort used before is not stable in the C# port). Refs #2871 --- packages/alphatab/src/midi/MidiFile.ts | 27 ++++++++++++++++--- .../test/audio/MidiFileGenerator.test.ts | 26 +++++++++++++++++- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/packages/alphatab/src/midi/MidiFile.ts b/packages/alphatab/src/midi/MidiFile.ts index f189d2bc8f..fb0026aac8 100644 --- a/packages/alphatab/src/midi/MidiFile.ts +++ b/packages/alphatab/src/midi/MidiFile.ts @@ -102,12 +102,31 @@ export class MidiFile { if (this.tracks.length === 1) { return this.tracks[0].events; } + // merge the (already sorted) tracks. this keeps the order of events on the same tick + // (sorting would not guarantee this on all platforms) const events: MidiEvent[] = []; - for (const t of this.tracks) { - this.events.push(...t.events); - } + const positions: number[] = this.tracks.map(_ => 0); + while (true) { + let nextTrack = -1; + let nextTick = 0; + for (let i = 0; i < this.tracks.length; i++) { + const trackEvents = this.tracks[i].events; + if (positions[i] < trackEvents.length) { + const tick = trackEvents[positions[i]].tick; + if (nextTrack === -1 || tick < nextTick) { + nextTrack = i; + nextTick = tick; + } + } + } - events.sort((a, b) => a.tick - b.tick); + if (nextTrack === -1) { + break; + } + + events.push(this.tracks[nextTrack].events[positions[nextTrack]]); + positions[nextTrack]++; + } return events; } diff --git a/packages/alphatab/test/audio/MidiFileGenerator.test.ts b/packages/alphatab/test/audio/MidiFileGenerator.test.ts index 7291cbb718..8296ee72c3 100644 --- a/packages/alphatab/test/audio/MidiFileGenerator.test.ts +++ b/packages/alphatab/test/audio/MidiFileGenerator.test.ts @@ -12,7 +12,7 @@ import { NoteOnEvent, type TimeSignatureEvent } from '@coderline/alphatab/midi/MidiEvent'; -import { MidiFile } from '@coderline/alphatab/midi/MidiFile'; +import { MidiFile, MidiFileFormat } from '@coderline/alphatab/midi/MidiFile'; import { MidiFileGenerator } from '@coderline/alphatab/midi/MidiFileGenerator'; import type { MidiTickLookup } from '@coderline/alphatab/midi/MidiTickLookup'; import { MidiUtils } from '@coderline/alphatab/midi/MidiUtils'; @@ -1989,6 +1989,30 @@ describe('MidiFileGeneratorTest', () => { expect(tempoChanges.join(' ')).toBe('960:60'); }); + it('multi-track-format-events', () => { + const score: Score = parseTex('\\track "T1" 3.3.4*4 | 3.3.1 \\track "T2" 3.4.2*2 | 3.4.1'); + const midi = new MidiFile(); + midi.format = MidiFileFormat.MultiTrack; + const generator: MidiFileGenerator = new MidiFileGenerator(score, null, new AlphaSynthMidiFileHandler(midi)); + generator.generate(); + + expect(midi.tracks.length).toBe(2); + const events = midi.events; + expect(events.length).toBe(midi.tracks[0].events.length + midi.tracks[1].events.length); + + // sorted by tick and keeping the order within each track + const positions: number[] = [0, 0]; + let previousTick = 0; + for (const e of events) { + expect(e.tick).toBeGreaterThanOrEqual(previousTick); + previousTick = e.tick; + const track = + positions[0] < midi.tracks[0].events.length && midi.tracks[0].events[positions[0]] === e ? 0 : 1; + expect(midi.tracks[track].events[positions[track]]).toBe(e); + positions[track]++; + } + }); + it('transpose', () => { const score = parseTex(` \\track \\staff \\instrument piano From 39596ea43ad9f9b57be379eaa76bd0ef4cb3d97c Mon Sep 17 00:00:00 2001 From: danielku15 Date: Thu, 8 Oct 2026 13:01:15 +0200 Subject: [PATCH 6/6] test: add reference for pick-up bar beaming Refs #2871 --- .../special-notes/beaming-anacrusis.png | Bin 0 -> 9739 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 packages/alphatab/test-data/visual-tests/special-notes/beaming-anacrusis.png diff --git a/packages/alphatab/test-data/visual-tests/special-notes/beaming-anacrusis.png b/packages/alphatab/test-data/visual-tests/special-notes/beaming-anacrusis.png new file mode 100644 index 0000000000000000000000000000000000000000..0d0c4546086402137cc21c9ef71b4df26f7bbe6c GIT binary patch literal 9739 zcmeHt_g7P0*KO$1l`d5z2uM{#=^#Z2MT%6BDoAhAJE#x=0qIhtC`Ed&1`=wd2uSaQ zULqX=NV_LIKJWMaaL0K6fO{DkCnRT=dDdQguDRAuxaLzOauP-o5C}vLQC83ffe2`T z|0}N%0$1TQc^L49?qe9{?7Io@@y;uugSoxE-D zRW)QwbNpB#;lrC~qYo6FYUvDI(JB^So*-?eCqwiW8J|BNpfIS%1n(#--+A5FoSbLPF~myaRWJa8$5{9@b2CldE7V$LChjs%7{@ z#OfZU5a z4%N8-8I8;m2OiU#=FKJq29r`1Ntu3Jzr~q5a}yzgU`BD8xecZl9#(ArJcvxVT4aw$ z+-WqGbCqsNsY|b@qH=l+L^O%W91YPW-iOP&m zonqoOq|uvd$T%lbdV$|fW-V_B_E&x~r4=v?dcgI|OlGV$h6{RAMeLnDJu$(5V4lPK z{W5beS9((>zVRsRlJ0iSiGfFLHWzk(n?7*Wzk-#lA4|Z5FB$ugh~% zx>sOXtRV_zzp8Kx7&jin8{Nes4fyo((bbMqNgOefali*gsb;?;r&^~O62v_uvoLo9 z>@EQN<#z)_Q)*$z_f%(J6iwvDtZj0!o4n6d;1Id{&9U3DCmdoZ@tc`EzVJCJ#Y zC`&cRKajla(S6%XCd3E?iP_afj+C>Am~MuUZq^h&`9kY*dq)+OBBb8VjC!X=bY&#F zvsAQX5K|jl=Nw5_n`dlgez3K@cT)*ZG$$7+QSP_+_=QhktHF;0Z~uIUAW1{^<@z_u zib-x`=hv2VQ|At3@SRKl^yRmmnLc^h(Hvqcr+tZyqrWQHm8b9Y8TnLfjyoer66c^K zr*HiZ%@zM7+D6M~KtaiRnwV#(AUoHe%LuPl3qg=GC;!fCm17P`J5^-`744S;1ocG` zmbW4M{a5sHKpRyGwQ$F?lX3Rvw4RFSp7@3P>@|o{m&ZK0fjqbKb6IIr8CMG7$aEtGro1?6NOV+*c zoUkeho~#}FQfvxuZsRS34Z5BGIaG3(sLUOsH}!XIFE#!i%`GS>W6_@>)nrs-cX%@0 z;Kr3J5iPOJ*Tv5}_2cYhAAJ~L)a2`tT<|RazWr#i`NYJ8A9{bXrp9Hyb(WNl&ql;~ z7WN_FN!p+Dz>AYrzut7Qcp^6c@?SyyswygX5|fhj#FQ3LEpNx@>FLMNXvo;<^zcmJ z>ETSB(|NJ_L9<$B;9_>#qadA=aIEtrm`4d@8|XY(DKy~o)K%{{YIx0A2HPV6OmdZ*V*jvK!`p62m$J zcU$eN+8ESxi?26j3plvJ7Ie0I!CwLQK)M%KG7GDyRm8tTu^{8dzWDczQS#Hyv-&jr&(CJq-=fY*I9(;BnomCEf#BJj3U z&F8t3_!KwiO88pJED@P#{D$8^j&N2t3*Zeu^O3AkCOf5Bxk_eGqBL zXM`={398@RWBpycTv>+%y5d|-oXTJ#cryT}n0yLDqW3E)^=;U5J}V{{;Q?O%Olh19 zqCiTRHqu@d>p9Oq=A~=$;jZ9p#zk)vHdFGE$~&HdGDqb7jDt->?eYA19kh;cDMRtc zN3zFaw5M3Y?v<68O)(cpbiey@znE6&#wVIM^o;zQCh0rhB`Q+eQcR1XwX--*#Za{J z)db~vshX0w$NfSHk6}gJ_>QIdBsQ5qXbc(dMp6z+{)i3_6sKPf21TLo)G#h}&G8aXNFp!HGZVQEuKrUt?y`d)oJ| zUrz9+f^*yE%?NqBYa^ehT$EU*#Kt?c850HOf8m#6mrOV77blIITg;Eo4>wEa3%Y9^ zr$$#Qdj!5aA)_x(Pn(Rtd)jMAY*sUIX=M0ccR~1!8GFra9nJb}ze{s&_OUE8Y3jCT z8d85WGwd*+GGI)eI!66K1R*7u>J1v4N zZoT({K1uq#mK4iqOqCA!Q@`AkJSXL|xAd%E99?{P=(AbanB`Ee-b@*|$<*`z%4gMcV-XKlPK~v-?-x{8 zS9dp%6H&7ItGc?nmMp?|T1k&Qevj{70P){_syX2F{b-4i^UuD%K*zO#?EV+-zZ9K8 zV&+BhqyqI>Zot#~j&P1ns*IhcQzV=Cu4~}D>irap5p!{dpa0o1stX*w<7=9y0pA_? znI@y+HH7sv9j^aPf`ye{a{2+0@_1KINHQ+0(;l`dBs(WPHuuyo+23{zysc(pCHzG;An-_cgCdYYi~ z{A`mS4>xyKeZ9A`p57>B++qQx5?jE7o@Bv%>;8<$LD%!{4{S0Sw7h!PBRf=>s?E(> z-w_u-uPJ;yDR7rj-f*JR*SY9Fx+W}e`|kZ$y$q}Y2fseR@`(>!;bUc{{oRSYhE=b~ z0qVVn6;0(kJvVP`yoT@Z@AL4NNEXL(DBZW}PQdD~A)Xr;rUy3HLqkKE0iyoZ#u4m$ z&H^Ab`#oN z!{*W&eZd_MDAspGcbW)oR(S`Hn^d4vtx29ICt`KW%gf9{=~Dk8RS^Zgt0kr`xz8d? z?yjsdetr*3@YNS@|E}_bJEvL(A~jib6*(%mrze7o%AZo~^QiFaa-n2ean%zGRONOH zROskZS(~AKj#p~S&NWqA&ecqr;Z869m;KwG_*+ELMJ*yoTsTgpJwwl0&N;2D>slx# zMg*qkNCt|w4ff3G+#p09+}-$1fEme-N}NAh$|(0eQ$>`_do7a0hhx#@Bo+SpnQaih z_65I4emnb{a0qc?`lT;JZJL8N1L3FXsfycBkTn;}DTF~>ocb6?C_g&Io`J^AV9KvjNc= z9Rnqds$fo{xbS)6Cl%>b(HE&O$+I)S3+9q@9*ua=(XXM~L?J_T!A|xp z=?d~;F%Kt=Oq8U?miaix-kCLGDE7R40};T1oW(DF+V~_;#NzmgrNJRv2s$J-J6~UZ zJf&K>y#UcgK3tocB!Do6WS;88`TcoFz7Gy+Detow-`I*hxB z$s(g!@@HMz$kYBDZD)?Q52TB^kT$%oRze|kjqQjAko8lfD)r9~yK|oe5hq?p_ zV^ZxlT1f?aw|;$9>W_C6%&dq zl`R9lTDZf(+OMURmAw`sHkUyN*SWy+(Sm1OPokM20M$DIO7f9Xna`-9^*uj~1L&#r z$x_1R9T<=lzq7nOX5#1AAA(0DO&8g*HV2m}gJ1l?@i3}$Y66%v;HFHVq#*q4K6a^= zH6(ZO0pV)3+V|PiZ}qcR($V@*{_K>hlV3*!ZM#LqVD-ZUfQYFLYV361RvYU)2m`{K zhn+nj@QYtG#EICC?(8DjhN`SJNJvR>fVk9#UO#pK11c}Sx_U2J==G9vo?41FRblsqHzC_%BdG+;|+ug^SVr$8QO>3=F*G_~^b%f8L~_p%L-~qF#zO9KGl_ zq6tWAa?`-*J`FD;d1$oRNFiR3J}IeEg=N>`y3=zkhor_}`4cNjc?({9d{T9N9#N?( zH}Ob2VFv$9x?`7kK3&wsfpZf>&@I>WEWVpgu{3skP$U6%&09vYpG3ZNk-fHUX!uoY zEF$bdomQ+?Y&P0f7psneJb1D+eFpDS>cal@naNSNAG8g55uzsez7I9|XbTt5odj5k zBPDXD7R?n;3CG%JSN|6oCn&}MluWa_>=CI{7xrEe3)Sum&(G{@LV~^Q-U0(W1;5!* zHRU!cuLoB)%f;PJa~3D&T_2U_y?HnlhQBh8U@>ojF5Wh(qge%acjmg%&WP@iqUNtp zDcag8jSTVd4Y(Q89x~p|KGqrZPtzKbcW$mE>Heoub$57}Dq=g-#3vwRY2JnnI^qDQ z^98?B>K?`2Y_;pt`r7&u!r7JB)>b$GO2+BG(6mIMo~@_9d^gGE;KH} zJ2Hqtq6 zj&Q9(m$R~)dGfqww}p7fDz;duUiJvH7|;@eX#3zd?=g*!fOf%rMV2C#S2N~3w~Zj~ zFh=BNBj-5~GGPrZRQqZHbaCNcihUC_wXpIa8)cXyQFBU50}i@J-khSA4aY5bPmM^2 zR6f{VUU2A}97;0lo}iz24x`5`!K(+dSK$&2u^kx>ljQQhc82E?w8nmn9wLN=}`apFlI^UKkCzL0;P<2iHxo+iGb|}f% zaD-5`w?jl_sQL2hnDF)F=h4v!Gc(O4SD-H0fvbmIA@B&~t19=(U*Z>8x2hvO9s>yP z^%fwSpaIA8(6*kQ9^}?+lSCa126G0|`-e4-Q<%3+en+0u6)%bMGkiCERx|vzreyoe zN5&^>>?MF0aM=ih`5O@E1}4PE51p5O-%8a>L+dYHoNfzxZxrNDO70-=c((gB0{7zS zJvS#kI*Sc~BpH1_SIljNhR+06{}U($plgUMA&{<|!NEc5!oo-1J1B{W$jG7D)4e_k z)z>}U5AO$EoH5oMwo_|NA1}pgWHbSp@V#Acm~sBo&w7WOQ%9jZ&+=d&KYwnj3&$|4 ze+LMCW$Ax&waRu2AV8}!d{$E0X8ren24W-LWxvcNYz{~S{%p@SeGlTJli*{G(I1xg zg9Fm{$nO2NR%PCSw5jiU?#w@0+Ti~prJ450D6#e(zDE5oqmLM(7C_WoPc0YP!wVvO zL8eB4CVe#P&4FZg>kaC#s1WZM&(~nvYFN!EEDVsKiwyYwl$`TlTCOlCexQ4D(>lFH zo>HnEK&{c+U3kClqqZa~K&c6XgW5nEfCOar`dAqo0Re&g=44H4tv#=dOy=NVx1iYn zFs{l*)uv8FM5m@+ZAE99+h7hvE%gD9ugxu?n^~oqbt}cSN8iP6{v(390Io;lcy7uU!-@s2XK_c>=rOuIzs7>PIjeDMt8=Zfl>#DEi1 z-z&AclSj7UY9$7+Z8Qc54hhy0*Yi7It(rsfppiAg_TaI>dP7hi9^`Aue1*}Hj|V5+ zlL7~UHg~=cdo-qfwi5!16Yi*FSwqIjM8gD-Dq%^ky0180n0GV~q9|&V59$cPYipxnCm;<`VYrh4 zr;fWS)`N2u-Bh?eDS5@=Y+;z(y)hEK1xJVHIMg8!MC^P`3BF0`0<3S|)sK8i;>Nev z<}|2gMw7b6q)_$2kl&7L=noF*ZMSzof#O!)>l>Q0Kd1|I zf4;15h;!qDii@zMO4cyCm7TMn?eZ4{O5yrb@DT)(z92}aAhTP7>#9#=`-_dVa`6PB zOG)hbr)wo7pAJR^5S)$*y@=G-?#g8^;LB9 z^#IaW))!|dUl(_eFD~iMRvy8{oYn_(^J!>l&oObT0zNQ6x-`j^DS%A;Hfvagt^1h8 zou=!F`eZK8&f!&`CA>`hCvEdLf3(|UPaSePzZG!N80l(b%p+b(g^(j^A9U*Q13_8| zxws3ZJ_Txhx*5X2{=xR|0d|;|;gZWe7wU3aZz}K7BzCOnUReQZb*z1H*O?Owxqj_S zq$phBQd2PJ5F0zcbWVe)+RGRc9(@`Qsy-*Vgn&u!1cg-4s>oFLL- z=O&9Z}dP>D+oZwpb< zKKuF;CKOYQou(kv2ARBntuy3%n@ZoMAovCslr_>7C7$J&_cjr1rNe8ceC(`n!^I#E z?`p5-<%yARGUBL_$fuG92OyXbcTp8W#p>95|LvtMJeE#pWm|d&mpb?!LJ4t-C<`V} zp-}s$qxRyjoAm_m@dR-Lf}gcsy!m@n1t4@yp^JmMqHMCfEM+L%d)TY+CxUAOT#aCHA`HCyj07Fd8>!cYGGE_Id{;mi2HfPYBRtVfCBi}F=ow*8NhcWp=$piAar zLf7lTzxzp+|Kh8jGe$@t#zGd`gjQ2z>;4|B0Chl4-XWZ7S>urHu=zfw;IWX7?BsvV z{NFv{@8AHSoG1A}&JJvRyB2XAqTFjc(aU-FNbGr zBe>!>{|;W`X{EfiY*k;Ph={axICYkr%Gdeo6r*_*##sA{vHsf|M;pdT;EC-jCN0H5 zK&JxMYTQzie!q9KpV0C#KcWz6H={LxuP21EfzpXi#mnF=L=av+j!_6wP7v<=&OF&3 zQwjW7pceaEfc8r~*md8AWjyL&Xb5$%G$J#&Bi6hV3E~Km$!D;bA{4#BaLpfA633sp zyN_@bhYb`&3d2vpeHo&KU`3s{7#IH1tNe_(h4MBdEf;DJ;@i1fes`FOh>Y+;W=Cu3 z43pi2gLupya^y}RpWaVVR>W7(w5R-83URzM`7jLQiDQU}HSZy|iVW^=J106;R2GP) z*tEn^(sg>UFu=EACJW8Bdye*uSPbZy!e^m|^U`NH z$GY67M>z(8H=>Web&7>!lA!JD66Tj_BWEA}Zjn>Y6&4t)DBnSA8Y`6-6lz`zr|7CL zR$eFur_0>^ZAy*VrZ~luJD-RJ&wT{GrQNA157HC$(i)eM;By(22E*w>Pw*sgg9w~T zo)~q}DRxid!6{FTL(=5ps(1$knKQLp&Nr@4ZrtcQaTLwCo6X?3O&N=!B5t;Bv)`bC zpq@;zCve3jZ*nYm5qEU=L8|M(zmLdHDRuXSfg`}-1?P5t5hOy(f=o z*4QSR-w#82^%*o8`|`~*;BQ+mT4F2P{R3rG8@JxZif1a6Hb_ZIC+J*8*i|QXe^j1P zpxTcVQd(L>xS9tdiR5kzO_a4Uzf*!{gs}l9q|+v=JCcvqMPl9izwQf}kexBt^MUb$ zR7VQkp7!sIKLo#jq_^0Jg%WoKCDli~*Z$Zmdv!|FOo1RW2JFWJt`4LN6Ue0I@e@86 zmJk6?QIJDm7}^);NFnHAoqT^wdiga-zg@L%Y*E{ENIp~%B27lzdr8*xr;l6;9B&nx zgPQ$Ve}>GN4Wn(~uJ#b$LE3@Y!@=xR&`{UDl6<$P1OlK79%peYx8#gAJ z*`D+TBi*fVXajB#LwDRNNiXr~B^8D;ym#(MSsA9dD#FYfjpT^@4i1wOBq9BzaQMD= zoq-b4MLgobYj?v%zg|z0Y(o03_ALKF^qJ6swA%7-7r|Pb4g`7jxz3T5mVwK1GCO9O zok!L3X&Y1Af+`t@Anxonj}_cG(*CdVeetWB#lO=P{PY!9Ui)+Rrd-K4Aj?bBWB)Z; zOSkG-_{1l9V82X!GE~77{k%HycNWbih9VqZpqoBb5yzUS75>gbaZ@)}o!>;?qksKH zlsNEo*q65GwR&a)W?ycK;(T=8hU4Fn|FYxXM*lSZf9_h~c|q@ZO45gcn}!0v?gBv+ LpDL8fnZ5ZxRz8>0 literal 0 HcmV?d00001