diff --git a/packages/alphatab/src/exporter/GpifWriter.ts b/packages/alphatab/src/exporter/GpifWriter.ts index b5eb34d08f..a087482bd6 100644 --- a/packages/alphatab/src/exporter/GpifWriter.ts +++ b/packages/alphatab/src/exporter/GpifWriter.ts @@ -664,7 +664,7 @@ export class GpifWriter { } if (beat.showTimer) { - beatNode.addElement('Timer').innerText = (beat.timer ?? 0).toString(); + beatNode.addElement('Timer').innerText = Math.round(beat.timer ?? 0).toString(); } this._writeBeatProperties(beatNode, beat); diff --git a/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageDefinitions.ts b/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageDefinitions.ts index c2c23b33d8..be18148493 100644 --- a/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageDefinitions.ts +++ b/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageDefinitions.ts @@ -472,7 +472,7 @@ export class AlphaTex1LanguageDefinitions { [ [[106], 0], [[106], 0], - [[106], 0], + [[106], 2], [[106], 3] ] ] diff --git a/packages/alphatab/src/midi/MidiUtils.ts b/packages/alphatab/src/midi/MidiUtils.ts index 7b27059cf5..773133a3a3 100644 --- a/packages/alphatab/src/midi/MidiUtils.ts +++ b/packages/alphatab/src/midi/MidiUtils.ts @@ -16,7 +16,7 @@ export class MidiUtils { * @returns The converted duration in milliseconds. */ public static ticksToMillis(ticks: number, tempo: number): number { - return (ticks * (60000.0 / (tempo * MidiUtils.QuarterTime))) | 0; + return (ticks * 60000.0) / (tempo * MidiUtils.QuarterTime); } /** diff --git a/packages/alphatab/test-data/audio/export-sync-points.pcm b/packages/alphatab/test-data/audio/export-sync-points.pcm index 39c6e11e24..148a4c94cf 100644 Binary files a/packages/alphatab/test-data/audio/export-sync-points.pcm and b/packages/alphatab/test-data/audio/export-sync-points.pcm differ diff --git a/packages/alphatab/test/audio/MidiFileGenerator.test.ts b/packages/alphatab/test/audio/MidiFileGenerator.test.ts index 8296ee72c3..15b67ca6d7 100644 --- a/packages/alphatab/test/audio/MidiFileGenerator.test.ts +++ b/packages/alphatab/test/audio/MidiFileGenerator.test.ts @@ -1933,6 +1933,32 @@ describe('MidiFileGeneratorTest', () => { expect(actualTimers.join(',')).toBe(expectedTimers.join(',')); }); + /** + * See #2952 + */ + it('beat-timer-non-integer-bar-duration', () => { + // a 4/4 bar at 148 BPM lasts 1621.6216...ms + const barCount = 200; + const barTex: string[] = []; + for (let i = 0; i < barCount; i++) { + barTex.push('C4.4 { timer } C4.4 * 3'); + } + const score: Score = parseTex(` + \\tempo 148 + . + ${barTex.join(' | ')} + `); + + const handler: FlatMidiEventGenerator = new FlatMidiEventGenerator(); + const generator: MidiFileGenerator = new MidiFileGenerator(score, null, handler); + generator.generate(); + + const bars = score.tracks[0].staves[0].bars; + for (let i = 0; i < barCount; i++) { + expect(bars[i].voices[0].beats[0].timer, `bar ${i}`).toBeCloseTo((i * 4 * 60000) / 148, 6); + } + }); + describe('metronome', () => { function testMetronome(tex: string, expectedClicks: string) { const score: Score = parseTex(tex); diff --git a/packages/alphatab/test/audio/SyncPoint.test.ts b/packages/alphatab/test/audio/SyncPoint.test.ts index 8526753d61..95cd902db1 100644 --- a/packages/alphatab/test/audio/SyncPoint.test.ts +++ b/packages/alphatab/test/audio/SyncPoint.test.ts @@ -6,6 +6,7 @@ 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'; +import type { Score } from '@coderline/alphatab/model/Score'; import { Settings } from '@coderline/alphatab/Settings'; import { BackingTrackPlayer, type IBackingTrackSynthOutput } from '@coderline/alphatab/synth/BackingTrackPlayer'; import { @@ -13,6 +14,7 @@ import { type IExternalMediaHandler, type IExternalMediaSynthOutput } from '@coderline/alphatab/synth/ExternalMediaPlayer'; +import type { IAlphaSynth } from '@coderline/alphatab/synth/IAlphaSynth'; import type { IAudioSampleSynthesizer } from '@coderline/alphatab/synth/IAudioSampleSynthesizer'; import type { ISynthOutputDevice } from '@coderline/alphatab/synth/ISynthOutput'; import { MidiFileSequencer } from '@coderline/alphatab/synth/MidiFileSequencer'; @@ -357,6 +359,89 @@ describe('SyncPointTests', () => { expect(events.map(e => `${e.currentTime},${e.originalTempo},${e.modifiedTempo}`)).toMatchSnapshot(); expect(testOutput.seekTimes).toMatchSnapshot(); }); + + /** + * See #2952 + */ + describe('non-integer-bar-duration', () => { + // a 4/4 bar at 148 BPM lasts 1621.6216...ms + const tempo = 148; + const barCount = 201; + const ticksPerBar = 3840; + const barMillis = (4 * 60000) / tempo; + const lastSyncTime = (barCount - 1) * barMillis; + + function createScore(): Score { + const barTex: string[] = []; + for (let i = 0; i < barCount; i++) { + barTex.push('C4.4 * 4'); + } + return ScoreLoader.loadAlphaTex(` + \\tempo ${tempo} + . + ${barTex.join(' | ')} + . + \\sync 0 0 0 + \\sync ${barCount - 1} 0 ${lastSyncTime} + `); + } + + function createGenerator(score: Score, midi: MidiFile): MidiFileGenerator { + const generator = new MidiFileGenerator(score, new Settings(), new AlphaSynthMidiFileHandler(midi)); + generator.generate(); + return generator; + } + + function expectPlayerInSync(player: IAlphaSynth, seek: (time: number) => void) { + const score = createScore(); + const midi = new MidiFile(); + const generator = createGenerator(score, midi); + + player.loadMidiFile(midi); + player.loadBackingTrack(score); + player.updateSyncPoints(generator.syncPoints); + + let tick = -1; + player.positionChanged.on(e => { + tick = e.currentTick; + }); + player.play(); + + for (let bar = 0; bar < barCount; bar++) { + seek(bar * barMillis); + const expectedTick = bar * ticksPerBar; + expect(Math.abs(tick - expectedTick), `bar ${bar}: tick ${tick}`).toBeLessThanOrEqual(1); + } + } + + it('sync-point-times', () => { + const generator = createGenerator(createScore(), new MidiFile()); + + const syncPoints = generator.syncPoints; + expect(syncPoints[1].synthTick).toBe((barCount - 1) * ticksPerBar); + expect(syncPoints[1].syncTime).toBe(lastSyncTime); + expect(syncPoints[1].synthTime).toBeCloseTo(lastSyncTime, 6); + expect(syncPoints[0].syncBpm).toBeCloseTo(tempo, 6); + }); + + it('external-media-position', () => { + const player = new ExternalMediaPlayer(500); + const output = player.output as IExternalMediaSynthOutput; + const mediaHandler = new TestExternalMediaHandler(output); + mediaHandler.backingTrackDuration = lastSyncTime + barMillis; + output.handler = mediaHandler; + + expectPlayerInSync(player, time => mediaHandler.simulateSeek(time)); + }); + + it('backing-track-position', () => { + const output = new TestBackingTrackOutput(); + output.backingTrackDuration = lastSyncTime + barMillis; + const player = new BackingTrackPlayer(output, 500); + + expectPlayerInSync(player, time => output.simulateSeek(time)); + }); + }); }); describe('MidiFileSequencerCountInTests', () => { diff --git a/packages/alphatab/test/audio/__snapshots__/SyncPoint.test.ts.snap b/packages/alphatab/test/audio/__snapshots__/SyncPoint.test.ts.snap index 99ad5e9795..3e01356c2f 100644 --- a/packages/alphatab/test/audio/__snapshots__/SyncPoint.test.ts.snap +++ b/packages/alphatab/test/audio/__snapshots__/SyncPoint.test.ts.snap @@ -10,7 +10,7 @@ exports[`SyncPointTests > modified-tempo-lookup 1`] = ` "22010.34013605442 => 120.04899959166994", "24009.52380952381 => 120.04899959167003", "30007.074829931975 => 80.01088583480758", - "33006.666666666664 => 80.01778172927317", + "33006.666666666664 => 80.01088583480758", ] `; @@ -50,15 +50,15 @@ exports[`SyncPointTests > playback-fast-backing-track 1`] = ` "18496.530140155122,80,160.02177166961516", "18996.59817662267,80,160.02177166961516", "19496.66621309022,80,160.02177166961516", - "19996.77706156924,80,160.03556345854633", - "20496.888197377197,80,160.03556345854633", - "20996.999333185155,80,160.03556345854633", - "21497.11046899311,80,160.03556345854633", - "21997.221604801067,80,160.03556345854633", - "22497.332740609025,80,160.03556345854633", - "22997.443876416983,80,160.03556345854633", - "23497.55501222494,80,160.03556345854633", - "23997.6661480329,80,160.03556345854633", + "19996.734249557765,80,160.02177166961516", + "20496.80228602531,80,160.02177166961516", + "20996.87032249286,80,160.02177166961516", + "21496.938358960408,80,160.02177166961516", + "21997.006395427954,80,160.02177166961516", + "22497.0744318955,80,160.02177166961516", + "22997.14246836305,80,160.02177166961516", + "23497.210504830597,80,160.02177166961516", + "23997.278541298143,80,160.02177166961516", ] `; @@ -98,15 +98,15 @@ exports[`SyncPointTests > playback-fast-external-media 1`] = ` "18496.530140155122,80,160.02177166961516", "18996.59817662267,80,160.02177166961516", "19496.66621309022,80,160.02177166961516", - "19996.77706156924,80,160.03556345854633", - "20496.888197377197,80,160.03556345854633", - "20996.999333185155,80,160.03556345854633", - "21497.11046899311,80,160.03556345854633", - "21997.221604801067,80,160.03556345854633", - "22497.332740609025,80,160.03556345854633", - "22997.443876416983,80,160.03556345854633", - "23497.55501222494,80,160.03556345854633", - "23997.6661480329,80,160.03556345854633", + "19996.734249557765,80,160.02177166961516", + "20496.80228602531,80,160.02177166961516", + "20996.87032249286,80,160.02177166961516", + "21496.938358960408,80,160.02177166961516", + "21997.006395427954,80,160.02177166961516", + "22497.0744318955,80,160.02177166961516", + "22997.14246836305,80,160.02177166961516", + "23497.210504830597,80,160.02177166961516", + "23997.278541298143,80,160.02177166961516", ] `; @@ -179,24 +179,24 @@ exports[`SyncPointTests > playback-normal-backing-track 1`] = ` "37993.19635324534,80,80.01088583480758", "38493.26438971289,80,80.01088583480758", "38993.33242618044,80,80.01088583480758", - "39493.44298733052,80,80.01778172927317", - "39993.55412313848,80,80.01778172927317", - "40493.665258946436,80,80.01778172927317", - "40993.77639475439,80,80.01778172927317", - "41493.88753056235,80,80.01778172927317", - "41993.99866637031,80,80.01778172927317", - "42494.10980217827,80,80.01778172927317", - "42994.22093798622,80,80.01778172927317", - "43494.332073794176,80,80.01778172927317", - "43994.443209602134,80,80.01778172927317", - "44494.55434541009,80,80.01778172927317", - "44994.66548121805,80,80.01778172927317", - "45494.77661702601,80,80.01778172927317", - "45994.887752833965,80,80.01778172927317", - "46494.99888864192,80,80.01778172927317", - "46995.11002444988,80,80.01778172927317", - "47495.22116025784,80,80.01778172927317", - "47995.3322960658,80,80.01778172927317", + "39493.40046264798,80,80.01088583480758", + "39993.46849911553,80,80.01088583480758", + "40493.53653558308,80,80.01088583480758", + "40993.60457205062,80,80.01088583480758", + "41493.67260851817,80,80.01088583480758", + "41993.74064498572,80,80.01088583480758", + "42493.808681453265,80,80.01088583480758", + "42993.876717920815,80,80.01088583480758", + "43493.94475438836,80,80.01088583480758", + "43994.01279085591,80,80.01088583480758", + "44494.08082732346,80,80.01088583480758", + "44994.148863791,80,80.01088583480758", + "45494.21690025855,80,80.01088583480758", + "45994.2849367261,80,80.01088583480758", + "46494.35297319364,80,80.01088583480758", + "46994.42100966119,80,80.01088583480758", + "47494.48904612874,80,80.01088583480758", + "47994.557082596286,80,80.01088583480758", ] `; @@ -269,24 +269,24 @@ exports[`SyncPointTests > playback-normal-external-media 1`] = ` "37993.19635324534,80,80.01088583480758", "38493.26438971289,80,80.01088583480758", "38993.33242618044,80,80.01088583480758", - "39493.44298733052,80,80.01778172927317", - "39993.55412313848,80,80.01778172927317", - "40493.665258946436,80,80.01778172927317", - "40993.77639475439,80,80.01778172927317", - "41493.88753056235,80,80.01778172927317", - "41993.99866637031,80,80.01778172927317", - "42494.10980217827,80,80.01778172927317", - "42994.22093798622,80,80.01778172927317", - "43494.332073794176,80,80.01778172927317", - "43994.443209602134,80,80.01778172927317", - "44494.55434541009,80,80.01778172927317", - "44994.66548121805,80,80.01778172927317", - "45494.77661702601,80,80.01778172927317", - "45994.887752833965,80,80.01778172927317", - "46494.99888864192,80,80.01778172927317", - "46995.11002444988,80,80.01778172927317", - "47495.22116025784,80,80.01778172927317", - "47995.3322960658,80,80.01778172927317", + "39493.40046264798,80,80.01088583480758", + "39993.46849911553,80,80.01088583480758", + "40493.53653558308,80,80.01088583480758", + "40993.60457205062,80,80.01088583480758", + "41493.67260851817,80,80.01088583480758", + "41993.74064498572,80,80.01088583480758", + "42493.808681453265,80,80.01088583480758", + "42993.876717920815,80,80.01088583480758", + "43493.94475438836,80,80.01088583480758", + "43994.01279085591,80,80.01088583480758", + "44494.08082732346,80,80.01088583480758", + "44994.148863791,80,80.01088583480758", + "45494.21690025855,80,80.01088583480758", + "45994.2849367261,80,80.01088583480758", + "46494.35297319364,80,80.01088583480758", + "46994.42100966119,80,80.01088583480758", + "47494.48904612874,80,80.01088583480758", + "47994.557082596286,80,80.01088583480758", ] `; @@ -311,7 +311,7 @@ exports[`SyncPointTests > seek-fast-backing-track 2`] = ` 4000, 8001.292517006803, 23343.12925170068, - 36006, + 36006.258503401354, ] `; @@ -336,7 +336,7 @@ exports[`SyncPointTests > seek-fast-external-media 2`] = ` 4000, 8001.292517006803, 23343.12925170068, - 36006, + 36006.258503401354, ] `; @@ -398,8 +398,8 @@ exports[`SyncPointTests > sync-point-generation 1`] = ` "8,0,80,120.04899959166994,24000,22010.34013605442", "9,0,80,120.04899959167003,27000,24009.52380952381", "8,1,80,80.01088583480758,36000,30007.074829931975", - "9,1,80,80.01778172927317,39000,33006.666666666664", - "11,0,80,80.01088583480758,48000,42004.666666666664", + "9,1,80,80.01088583480758,39000,33006.666666666664", + "11,0,80,80.01088583480758,48000,42005.44217687073", ] `; @@ -422,7 +422,7 @@ exports[`SyncPointTests > sync-point-update 1`] = ` "8,0,80,120.04899959166994,24000,22010.34013605442", "9,0,80,120.04899959167003,27000,24009.52380952381", "8,1,80,80.01088583480758,36000,30007.074829931975", - "9,1,80,80.01778172927317,39000,33006.666666666664", - "11,0,80,80.01088583480758,48000,42004.666666666664", + "9,1,80,80.01088583480758,39000,33006.666666666664", + "11,0,80,80.01088583480758,48000,42005.44217687073", ] `; diff --git a/packages/alphatab/test/exporter/Gp7Exporter.test.ts b/packages/alphatab/test/exporter/Gp7Exporter.test.ts index af7ded3912..eaa1e4459d 100644 --- a/packages/alphatab/test/exporter/Gp7Exporter.test.ts +++ b/packages/alphatab/test/exporter/Gp7Exporter.test.ts @@ -184,6 +184,20 @@ describe('Gp7ExporterTest', () => { expect(gpif).toContain('1>'); }); + it('beat-timer-as-integer', () => { + const score = ScoreLoader.loadAlphaTex(` + C4.4 { timer } C4.4 { timer } + `); + const beats = score.tracks[0].staves[0].bars[0].voices[0].beats; + beats[0].timer = 1621.4; + beats[1].timer = 3243.6; + + const gpif = readExportedGpif(exportGp7(score)); + + expect(gpif).toContain('1621'); + expect(gpif).toContain('3244'); + }); + it('alphatex-drums-to-gp7', () => { const tex = `\\track "Drums" { instrument percussion } \\clef neutral diff --git a/packages/alphatab/test/importer/AlphaTexImporter.test.ts b/packages/alphatab/test/importer/AlphaTexImporter.test.ts index c37fd57fa7..58880c5537 100644 --- a/packages/alphatab/test/importer/AlphaTexImporter.test.ts +++ b/packages/alphatab/test/importer/AlphaTexImporter.test.ts @@ -2241,6 +2241,31 @@ describe('AlphaTexImporterTest', () => { testExportRoundtrip(score); }); + it('sync-float-millisecond-offset', () => { + const score = parseTex(` + \\tempo 148 + . + 3.3.4*4 | 3.3.4*4 | 3.3.4*4 | 3.3.4*4 + . + \\sync 0 0 0 + \\sync 1 0 1621.6216216216217 + \\sync 2 0 3243.25 0.5 + \\sync (3 0 4864.865) + `); + + expect(score.masterBars.length).toBe(4); + expect(score.tracks[0].staves[0].bars[3].voices[0].beats.length).toBe(4); + const syncPoints = score.exportFlatSyncPoints(); + expect(syncPoints.map(p => `${p.barIndex},${p.barOccurence},${p.barPosition},${p.millisecondOffset}`)).toEqual([ + '0,0,0,0', + '1,0,0,1621.6216216216217', + '2,0,0.5,3243.25', + '3,0,0,4864.865' + ]); + + testExportRoundtrip(score); + }); + it('tuning-name', () => { const score = parseTex(` \\tuning E4 B3 G3 D3 A2 E2 "Default" diff --git a/packages/alphatex/src/metadata/bar/sync.ts b/packages/alphatex/src/metadata/bar/sync.ts index 03cf2eb864..cdb24edfbf 100644 --- a/packages/alphatex/src/metadata/bar/sync.ts +++ b/packages/alphatex/src/metadata/bar/sync.ts @@ -34,7 +34,7 @@ export const sync: MetadataTagDefinition = { { name: 'millisecondOffset', shortDescription: 'The absolute millisecond offset within the external media', - parseMode: alphaTab.importer.alphaTex.ArgumentListParseTypesMode.Required, + parseMode: alphaTab.importer.alphaTex.ArgumentListParseTypesMode.RequiredAsFloat, type: alphaTab.importer.alphaTex.AlphaTexNodeType.Number }, {