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/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/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/src/midi/MidiFileGenerator.ts b/packages/alphatab/src/midi/MidiFileGenerator.ts index 7649d0b89c..41c6ecc310 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'; @@ -152,25 +153,34 @@ 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(); } }, - (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); } } } + // 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) { @@ -284,7 +294,7 @@ export class MidiFileGenerator { (_masterBar, _previousMasterBar, _currentTick, _currentTempo, _barOccurence) => { // no generation }, - (_barIndex, _currentTick, _currentTempo) => { + (_barIndex, _currentTick, _currentTempo, _currentTime) => { // no generation }, _endTick => { @@ -308,7 +318,7 @@ export class MidiFileGenerator { (_masterBar, _previousMasterBar, _currentTick, _currentTempo, _barOccurence) => { // no generation }, - (_barIndex, _currentTick, _currentTempo) => { + (_barIndex, _currentTick, _currentTempo, _currentTime) => { // no generation }, _endTick => { @@ -330,7 +340,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); @@ -358,7 +368,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); @@ -453,9 +464,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 +491,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 +500,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 +508,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 +552,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 +567,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; @@ -582,7 +590,7 @@ export class MidiFileGenerator { currentTick: number, currentTempo: number, _barOccurence: number - ): void { + ): number { // time signature if ( !previousMasterBar || @@ -606,7 +614,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)); } @@ -621,58 +629,44 @@ 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): 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); } } @@ -706,10 +700,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; @@ -853,8 +845,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; } @@ -2412,7 +2405,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/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/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/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/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-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 0000000000..0d0c454608 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/special-notes/beaming-anacrusis.png differ 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 78664f690e..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'; @@ -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', () => { @@ -1833,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 @@ -1880,6 +1933,86 @@ 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'); + 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('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 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); + } +} 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); 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( `