Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions packages/alphatab/src/importer/AlphaTexImporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 9 additions & 0 deletions packages/alphatab/src/midi/AlphaSynthMidiFileHandler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
AlphaTabMetronomeEvent,
AlphaTabRestEvent,
ControlChangeEvent,
EndOfTrackEvent,
Expand Down Expand Up @@ -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(
Expand Down
11 changes: 11 additions & 0 deletions packages/alphatab/src/midi/IMidiFileHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

}
5 changes: 3 additions & 2 deletions packages/alphatab/src/midi/MidiEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
27 changes: 23 additions & 4 deletions packages/alphatab/src/midi/MidiFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
119 changes: 56 additions & 63 deletions packages/alphatab/src/midi/MidiFileGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -284,7 +294,7 @@ export class MidiFileGenerator {
(_masterBar, _previousMasterBar, _currentTick, _currentTempo, _barOccurence) => {
// no generation
},
(_barIndex, _currentTick, _currentTempo) => {
(_barIndex, _currentTick, _currentTempo, _currentTime) => {
// no generation
},
_endTick => {
Expand All @@ -308,7 +318,7 @@ export class MidiFileGenerator {
(_masterBar, _previousMasterBar, _currentTick, _currentTempo, _barOccurence) => {
// no generation
},
(_barIndex, _currentTick, _currentTempo) => {
(_barIndex, _currentTick, _currentTempo, _currentTime) => {
// no generation
},
_endTick => {
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -491,15 +500,15 @@ 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 (
tempoChangeIndex < bar.tempoAutomations.length &&
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) {
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -582,7 +590,7 @@ export class MidiFileGenerator {
currentTick: number,
currentTempo: number,
_barOccurence: number
): void {
): number {
// time signature
if (
!previousMasterBar ||
Expand All @@ -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));
}
Expand All @@ -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);
}
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions packages/alphatab/src/model/Automation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading
Loading