Skip to content

Commit 2fb351d

Browse files
authored
fix(player): keep sync point times exact to avoid drift on non-integer tempos (#2960)
1 parent 48433c2 commit 2fb351d

10 files changed

Lines changed: 215 additions & 65 deletions

File tree

‎packages/alphatab/src/exporter/GpifWriter.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -664,7 +664,7 @@ export class GpifWriter {
664664
}
665665

666666
if (beat.showTimer) {
667-
beatNode.addElement('Timer').innerText = (beat.timer ?? 0).toString();
667+
beatNode.addElement('Timer').innerText = Math.round(beat.timer ?? 0).toString();
668668
}
669669

670670
this._writeBeatProperties(beatNode, beat);

‎packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageDefinitions.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -472,7 +472,7 @@ export class AlphaTex1LanguageDefinitions {
472472
[
473473
[[106], 0],
474474
[[106], 0],
475-
[[106], 0],
475+
[[106], 2],
476476
[[106], 3]
477477
]
478478
]

‎packages/alphatab/src/midi/MidiUtils.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export class MidiUtils {
1616
* @returns The converted duration in milliseconds.
1717
*/
1818
public static ticksToMillis(ticks: number, tempo: number): number {
19-
return (ticks * (60000.0 / (tempo * MidiUtils.QuarterTime))) | 0;
19+
return (ticks * 60000.0) / (tempo * MidiUtils.QuarterTime);
2020
}
2121

2222
/**
512 Bytes
Binary file not shown.

‎packages/alphatab/test/audio/MidiFileGenerator.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1933,6 +1933,32 @@ describe('MidiFileGeneratorTest', () => {
19331933
expect(actualTimers.join(',')).toBe(expectedTimers.join(','));
19341934
});
19351935

1936+
/**
1937+
* See #2952
1938+
*/
1939+
it('beat-timer-non-integer-bar-duration', () => {
1940+
// a 4/4 bar at 148 BPM lasts 1621.6216...ms
1941+
const barCount = 200;
1942+
const barTex: string[] = [];
1943+
for (let i = 0; i < barCount; i++) {
1944+
barTex.push('C4.4 { timer } C4.4 * 3');
1945+
}
1946+
const score: Score = parseTex(`
1947+
\\tempo 148
1948+
.
1949+
${barTex.join(' | ')}
1950+
`);
1951+
1952+
const handler: FlatMidiEventGenerator = new FlatMidiEventGenerator();
1953+
const generator: MidiFileGenerator = new MidiFileGenerator(score, null, handler);
1954+
generator.generate();
1955+
1956+
const bars = score.tracks[0].staves[0].bars;
1957+
for (let i = 0; i < barCount; i++) {
1958+
expect(bars[i].voices[0].beats[0].timer, `bar ${i}`).toBeCloseTo((i * 4 * 60000) / 148, 6);
1959+
}
1960+
});
1961+
19361962
describe('metronome', () => {
19371963
function testMetronome(tex: string, expectedClicks: string) {
19381964
const score: Score = parseTex(tex);

‎packages/alphatab/test/audio/SyncPoint.test.ts‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ import type { AlphaTabMetronomeEvent } from '@coderline/alphatab/midi/MidiEvent'
66
import { MidiFile } from '@coderline/alphatab/midi/MidiFile';
77
import { MidiFileGenerator } from '@coderline/alphatab/midi/MidiFileGenerator';
88
import type { BackingTrack } from '@coderline/alphatab/model/BackingTrack';
9+
import type { Score } from '@coderline/alphatab/model/Score';
910
import { Settings } from '@coderline/alphatab/Settings';
1011
import { BackingTrackPlayer, type IBackingTrackSynthOutput } from '@coderline/alphatab/synth/BackingTrackPlayer';
1112
import {
1213
ExternalMediaPlayer,
1314
type IExternalMediaHandler,
1415
type IExternalMediaSynthOutput
1516
} from '@coderline/alphatab/synth/ExternalMediaPlayer';
17+
import type { IAlphaSynth } from '@coderline/alphatab/synth/IAlphaSynth';
1618
import type { IAudioSampleSynthesizer } from '@coderline/alphatab/synth/IAudioSampleSynthesizer';
1719
import type { ISynthOutputDevice } from '@coderline/alphatab/synth/ISynthOutput';
1820
import { MidiFileSequencer } from '@coderline/alphatab/synth/MidiFileSequencer';
@@ -357,6 +359,89 @@ describe('SyncPointTests', () => {
357359
expect(events.map(e => `${e.currentTime},${e.originalTempo},${e.modifiedTempo}`)).toMatchSnapshot();
358360
expect(testOutput.seekTimes).toMatchSnapshot();
359361
});
362+
363+
/**
364+
* See #2952
365+
*/
366+
describe('non-integer-bar-duration', () => {
367+
// a 4/4 bar at 148 BPM lasts 1621.6216...ms
368+
const tempo = 148;
369+
const barCount = 201;
370+
const ticksPerBar = 3840;
371+
const barMillis = (4 * 60000) / tempo;
372+
const lastSyncTime = (barCount - 1) * barMillis;
373+
374+
function createScore(): Score {
375+
const barTex: string[] = [];
376+
for (let i = 0; i < barCount; i++) {
377+
barTex.push('C4.4 * 4');
378+
}
379+
return ScoreLoader.loadAlphaTex(`
380+
\\tempo ${tempo}
381+
.
382+
${barTex.join(' | ')}
383+
.
384+
\\sync 0 0 0
385+
\\sync ${barCount - 1} 0 ${lastSyncTime}
386+
`);
387+
}
388+
389+
function createGenerator(score: Score, midi: MidiFile): MidiFileGenerator {
390+
const generator = new MidiFileGenerator(score, new Settings(), new AlphaSynthMidiFileHandler(midi));
391+
generator.generate();
392+
return generator;
393+
}
394+
395+
function expectPlayerInSync(player: IAlphaSynth, seek: (time: number) => void) {
396+
const score = createScore();
397+
const midi = new MidiFile();
398+
const generator = createGenerator(score, midi);
399+
400+
player.loadMidiFile(midi);
401+
player.loadBackingTrack(score);
402+
player.updateSyncPoints(generator.syncPoints);
403+
404+
let tick = -1;
405+
player.positionChanged.on(e => {
406+
tick = e.currentTick;
407+
});
408+
player.play();
409+
410+
for (let bar = 0; bar < barCount; bar++) {
411+
seek(bar * barMillis);
412+
const expectedTick = bar * ticksPerBar;
413+
expect(Math.abs(tick - expectedTick), `bar ${bar}: tick ${tick}`).toBeLessThanOrEqual(1);
414+
}
415+
}
416+
417+
it('sync-point-times', () => {
418+
const generator = createGenerator(createScore(), new MidiFile());
419+
420+
const syncPoints = generator.syncPoints;
421+
expect(syncPoints[1].synthTick).toBe((barCount - 1) * ticksPerBar);
422+
expect(syncPoints[1].syncTime).toBe(lastSyncTime);
423+
expect(syncPoints[1].synthTime).toBeCloseTo(lastSyncTime, 6);
424+
expect(syncPoints[0].syncBpm).toBeCloseTo(tempo, 6);
425+
});
426+
427+
it('external-media-position', () => {
428+
const player = new ExternalMediaPlayer(500);
429+
const output = player.output as IExternalMediaSynthOutput;
430+
const mediaHandler = new TestExternalMediaHandler(output);
431+
mediaHandler.backingTrackDuration = lastSyncTime + barMillis;
432+
output.handler = mediaHandler;
433+
434+
expectPlayerInSync(player, time => mediaHandler.simulateSeek(time));
435+
});
436+
437+
it('backing-track-position', () => {
438+
const output = new TestBackingTrackOutput();
439+
output.backingTrackDuration = lastSyncTime + barMillis;
440+
const player = new BackingTrackPlayer(output, 500);
441+
442+
expectPlayerInSync(player, time => output.simulateSeek(time));
443+
});
444+
});
360445
});
361446

362447
describe('MidiFileSequencerCountInTests', () => {

‎packages/alphatab/test/audio/__snapshots__/SyncPoint.test.ts.snap‎

Lines changed: 61 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ exports[`SyncPointTests > modified-tempo-lookup 1`] = `
1010
"22010.34013605442 => 120.04899959166994",
1111
"24009.52380952381 => 120.04899959167003",
1212
"30007.074829931975 => 80.01088583480758",
13-
"33006.666666666664 => 80.01778172927317",
13+
"33006.666666666664 => 80.01088583480758",
1414
]
1515
`;
1616

@@ -50,15 +50,15 @@ exports[`SyncPointTests > playback-fast-backing-track 1`] = `
5050
"18496.530140155122,80,160.02177166961516",
5151
"18996.59817662267,80,160.02177166961516",
5252
"19496.66621309022,80,160.02177166961516",
53-
"19996.77706156924,80,160.03556345854633",
54-
"20496.888197377197,80,160.03556345854633",
55-
"20996.999333185155,80,160.03556345854633",
56-
"21497.11046899311,80,160.03556345854633",
57-
"21997.221604801067,80,160.03556345854633",
58-
"22497.332740609025,80,160.03556345854633",
59-
"22997.443876416983,80,160.03556345854633",
60-
"23497.55501222494,80,160.03556345854633",
61-
"23997.6661480329,80,160.03556345854633",
53+
"19996.734249557765,80,160.02177166961516",
54+
"20496.80228602531,80,160.02177166961516",
55+
"20996.87032249286,80,160.02177166961516",
56+
"21496.938358960408,80,160.02177166961516",
57+
"21997.006395427954,80,160.02177166961516",
58+
"22497.0744318955,80,160.02177166961516",
59+
"22997.14246836305,80,160.02177166961516",
60+
"23497.210504830597,80,160.02177166961516",
61+
"23997.278541298143,80,160.02177166961516",
6262
]
6363
`;
6464

@@ -98,15 +98,15 @@ exports[`SyncPointTests > playback-fast-external-media 1`] = `
9898
"18496.530140155122,80,160.02177166961516",
9999
"18996.59817662267,80,160.02177166961516",
100100
"19496.66621309022,80,160.02177166961516",
101-
"19996.77706156924,80,160.03556345854633",
102-
"20496.888197377197,80,160.03556345854633",
103-
"20996.999333185155,80,160.03556345854633",
104-
"21497.11046899311,80,160.03556345854633",
105-
"21997.221604801067,80,160.03556345854633",
106-
"22497.332740609025,80,160.03556345854633",
107-
"22997.443876416983,80,160.03556345854633",
108-
"23497.55501222494,80,160.03556345854633",
109-
"23997.6661480329,80,160.03556345854633",
101+
"19996.734249557765,80,160.02177166961516",
102+
"20496.80228602531,80,160.02177166961516",
103+
"20996.87032249286,80,160.02177166961516",
104+
"21496.938358960408,80,160.02177166961516",
105+
"21997.006395427954,80,160.02177166961516",
106+
"22497.0744318955,80,160.02177166961516",
107+
"22997.14246836305,80,160.02177166961516",
108+
"23497.210504830597,80,160.02177166961516",
109+
"23997.278541298143,80,160.02177166961516",
110110
]
111111
`;
112112

@@ -179,24 +179,24 @@ exports[`SyncPointTests > playback-normal-backing-track 1`] = `
179179
"37993.19635324534,80,80.01088583480758",
180180
"38493.26438971289,80,80.01088583480758",
181181
"38993.33242618044,80,80.01088583480758",
182-
"39493.44298733052,80,80.01778172927317",
183-
"39993.55412313848,80,80.01778172927317",
184-
"40493.665258946436,80,80.01778172927317",
185-
"40993.77639475439,80,80.01778172927317",
186-
"41493.88753056235,80,80.01778172927317",
187-
"41993.99866637031,80,80.01778172927317",
188-
"42494.10980217827,80,80.01778172927317",
189-
"42994.22093798622,80,80.01778172927317",
190-
"43494.332073794176,80,80.01778172927317",
191-
"43994.443209602134,80,80.01778172927317",
192-
"44494.55434541009,80,80.01778172927317",
193-
"44994.66548121805,80,80.01778172927317",
194-
"45494.77661702601,80,80.01778172927317",
195-
"45994.887752833965,80,80.01778172927317",
196-
"46494.99888864192,80,80.01778172927317",
197-
"46995.11002444988,80,80.01778172927317",
198-
"47495.22116025784,80,80.01778172927317",
199-
"47995.3322960658,80,80.01778172927317",
182+
"39493.40046264798,80,80.01088583480758",
183+
"39993.46849911553,80,80.01088583480758",
184+
"40493.53653558308,80,80.01088583480758",
185+
"40993.60457205062,80,80.01088583480758",
186+
"41493.67260851817,80,80.01088583480758",
187+
"41993.74064498572,80,80.01088583480758",
188+
"42493.808681453265,80,80.01088583480758",
189+
"42993.876717920815,80,80.01088583480758",
190+
"43493.94475438836,80,80.01088583480758",
191+
"43994.01279085591,80,80.01088583480758",
192+
"44494.08082732346,80,80.01088583480758",
193+
"44994.148863791,80,80.01088583480758",
194+
"45494.21690025855,80,80.01088583480758",
195+
"45994.2849367261,80,80.01088583480758",
196+
"46494.35297319364,80,80.01088583480758",
197+
"46994.42100966119,80,80.01088583480758",
198+
"47494.48904612874,80,80.01088583480758",
199+
"47994.557082596286,80,80.01088583480758",
200200
]
201201
`;
202202

@@ -269,24 +269,24 @@ exports[`SyncPointTests > playback-normal-external-media 1`] = `
269269
"37993.19635324534,80,80.01088583480758",
270270
"38493.26438971289,80,80.01088583480758",
271271
"38993.33242618044,80,80.01088583480758",
272-
"39493.44298733052,80,80.01778172927317",
273-
"39993.55412313848,80,80.01778172927317",
274-
"40493.665258946436,80,80.01778172927317",
275-
"40993.77639475439,80,80.01778172927317",
276-
"41493.88753056235,80,80.01778172927317",
277-
"41993.99866637031,80,80.01778172927317",
278-
"42494.10980217827,80,80.01778172927317",
279-
"42994.22093798622,80,80.01778172927317",
280-
"43494.332073794176,80,80.01778172927317",
281-
"43994.443209602134,80,80.01778172927317",
282-
"44494.55434541009,80,80.01778172927317",
283-
"44994.66548121805,80,80.01778172927317",
284-
"45494.77661702601,80,80.01778172927317",
285-
"45994.887752833965,80,80.01778172927317",
286-
"46494.99888864192,80,80.01778172927317",
287-
"46995.11002444988,80,80.01778172927317",
288-
"47495.22116025784,80,80.01778172927317",
289-
"47995.3322960658,80,80.01778172927317",
272+
"39493.40046264798,80,80.01088583480758",
273+
"39993.46849911553,80,80.01088583480758",
274+
"40493.53653558308,80,80.01088583480758",
275+
"40993.60457205062,80,80.01088583480758",
276+
"41493.67260851817,80,80.01088583480758",
277+
"41993.74064498572,80,80.01088583480758",
278+
"42493.808681453265,80,80.01088583480758",
279+
"42993.876717920815,80,80.01088583480758",
280+
"43493.94475438836,80,80.01088583480758",
281+
"43994.01279085591,80,80.01088583480758",
282+
"44494.08082732346,80,80.01088583480758",
283+
"44994.148863791,80,80.01088583480758",
284+
"45494.21690025855,80,80.01088583480758",
285+
"45994.2849367261,80,80.01088583480758",
286+
"46494.35297319364,80,80.01088583480758",
287+
"46994.42100966119,80,80.01088583480758",
288+
"47494.48904612874,80,80.01088583480758",
289+
"47994.557082596286,80,80.01088583480758",
290290
]
291291
`;
292292

@@ -311,7 +311,7 @@ exports[`SyncPointTests > seek-fast-backing-track 2`] = `
311311
4000,
312312
8001.292517006803,
313313
23343.12925170068,
314-
36006,
314+
36006.258503401354,
315315
]
316316
`;
317317

@@ -336,7 +336,7 @@ exports[`SyncPointTests > seek-fast-external-media 2`] = `
336336
4000,
337337
8001.292517006803,
338338
23343.12925170068,
339-
36006,
339+
36006.258503401354,
340340
]
341341
`;
342342

@@ -398,8 +398,8 @@ exports[`SyncPointTests > sync-point-generation 1`] = `
398398
"8,0,80,120.04899959166994,24000,22010.34013605442",
399399
"9,0,80,120.04899959167003,27000,24009.52380952381",
400400
"8,1,80,80.01088583480758,36000,30007.074829931975",
401-
"9,1,80,80.01778172927317,39000,33006.666666666664",
402-
"11,0,80,80.01088583480758,48000,42004.666666666664",
401+
"9,1,80,80.01088583480758,39000,33006.666666666664",
402+
"11,0,80,80.01088583480758,48000,42005.44217687073",
403403
]
404404
`;
405405

@@ -422,7 +422,7 @@ exports[`SyncPointTests > sync-point-update 1`] = `
422422
"8,0,80,120.04899959166994,24000,22010.34013605442",
423423
"9,0,80,120.04899959167003,27000,24009.52380952381",
424424
"8,1,80,80.01088583480758,36000,30007.074829931975",
425-
"9,1,80,80.01778172927317,39000,33006.666666666664",
426-
"11,0,80,80.01088583480758,48000,42004.666666666664",
425+
"9,1,80,80.01088583480758,39000,33006.666666666664",
426+
"11,0,80,80.01088583480758,48000,42005.44217687073",
427427
]
428428
`;

‎packages/alphatab/test/exporter/Gp7Exporter.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,20 @@ describe('Gp7ExporterTest', () => {
184184
expect(gpif).toContain('<MultiVoice>1></MultiVoice>');
185185
});
186186

187+
it('beat-timer-as-integer', () => {
188+
const score = ScoreLoader.loadAlphaTex(`
189+
C4.4 { timer } C4.4 { timer }
190+
`);
191+
const beats = score.tracks[0].staves[0].bars[0].voices[0].beats;
192+
beats[0].timer = 1621.4;
193+
beats[1].timer = 3243.6;
194+
195+
const gpif = readExportedGpif(exportGp7(score));
196+
197+
expect(gpif).toContain('<Timer>1621</Timer>');
198+
expect(gpif).toContain('<Timer>3244</Timer>');
199+
});
200+
187201
it('alphatex-drums-to-gp7', () => {
188202
const tex = `\\track "Drums" { instrument percussion }
189203
\\clef neutral

0 commit comments

Comments
 (0)