From e66c2c5fdea3df63087bafee340f009b63dfe2c3 Mon Sep 17 00:00:00 2001 From: danielku15 Date: Wed, 7 Oct 2026 12:11:04 +0200 Subject: [PATCH 1/3] test(importer): cover MusicXML fingering per instrument and hand --- .../test-data/musicxml4/fingering.xml | 570 ++++++++++++++++++ .../test/importer/MusicXmlImporter.test.ts | 76 +++ 2 files changed, 646 insertions(+) create mode 100644 packages/alphatab/test-data/musicxml4/fingering.xml diff --git a/packages/alphatab/test-data/musicxml4/fingering.xml b/packages/alphatab/test-data/musicxml4/fingering.xml new file mode 100644 index 000000000..a4f80ffc1 --- /dev/null +++ b/packages/alphatab/test-data/musicxml4/fingering.xml @@ -0,0 +1,570 @@ + + + + + + Classical Guitar + + Classical Guitar + + + 1 + 25 + + + + Piano + + Piano + + + 2 + 1 + + + + + + + 1 + 0 + + G2-1 + + + A4 + 1quarter + 0 + + + A4 + 1quarter + 1 + + + A4 + 1quarter + 2 + + + A4 + 1quarter + 3 + + + + + A4 + 1quarter + 4 + + + A4 + 1quarter + 5 + + + A4 + 1quarter + p + + + A4 + 1quarter + t + + + + + A4 + 1quarter + T + + + A4 + 1quarter + P + + + A4 + 1quarter + i + + + A4 + 1quarter + I + + + + + A4 + 1quarter + m + + + A4 + 1quarter + M + + + A4 + 1quarter + a + + + A4 + 1quarter + A + + + + + A4 + 1quarter + c + + + A4 + 1quarter + C + + + A4 + 1quarter + e + + + A4 + 1quarter + o + + + + + A4 + 1quarter + q + + + A4 + 1quarter + s + + + A4 + 1quarter + x + + + A4 + 1quarter + 6 + + + + + A4 + 1quarter + 0 + + + A4 + 1quarter + 1 + + + A4 + 1quarter + 2 + + + A4 + 1quarter + 3 + + + + + A4 + 1quarter + 4 + + + A4 + 1quarter + 5 + + + A4 + 1quarter + p + + + A4 + 1quarter + t + + + + + A4 + 1quarter + T + + + A4 + 1quarter + P + + + A4 + 1quarter + i + + + A4 + 1quarter + I + + + + + A4 + 1quarter + m + + + A4 + 1quarter + M + + + A4 + 1quarter + a + + + A4 + 1quarter + A + + + + + A4 + 1quarter + c + + + A4 + 1quarter + C + + + A4 + 1quarter + e + + + A4 + 1quarter + o + + + + + A4 + 1quarter + q + + + A4 + 1quarter + s + + + A4 + 1quarter + x + + + A4 + 1quarter + 6 + + + + + + + 1 + 0 + + G2 + + + A4 + 1quarter + 0 + + + A4 + 1quarter + 1 + + + A4 + 1quarter + 2 + + + A4 + 1quarter + 3 + + + + + A4 + 1quarter + 4 + + + A4 + 1quarter + 5 + + + A4 + 1quarter + p + + + A4 + 1quarter + t + + + + + A4 + 1quarter + T + + + A4 + 1quarter + P + + + A4 + 1quarter + i + + + A4 + 1quarter + I + + + + + A4 + 1quarter + m + + + A4 + 1quarter + M + + + A4 + 1quarter + a + + + A4 + 1quarter + A + + + + + A4 + 1quarter + c + + + A4 + 1quarter + C + + + A4 + 1quarter + e + + + A4 + 1quarter + o + + + + + A4 + 1quarter + q + + + A4 + 1quarter + s + + + A4 + 1quarter + x + + + A4 + 1quarter + 6 + + + + + A4 + 1quarter + 0 + + + A4 + 1quarter + 1 + + + A4 + 1quarter + 2 + + + A4 + 1quarter + 3 + + + + + A4 + 1quarter + 4 + + + A4 + 1quarter + 5 + + + A4 + 1quarter + p + + + A4 + 1quarter + t + + + + + A4 + 1quarter + T + + + A4 + 1quarter + P + + + A4 + 1quarter + i + + + A4 + 1quarter + I + + + + + A4 + 1quarter + m + + + A4 + 1quarter + M + + + A4 + 1quarter + a + + + A4 + 1quarter + A + + + + + A4 + 1quarter + c + + + A4 + 1quarter + C + + + A4 + 1quarter + e + + + A4 + 1quarter + o + + + + + A4 + 1quarter + q + + + A4 + 1quarter + s + + + A4 + 1quarter + x + + + A4 + 1quarter + 6 + + + + diff --git a/packages/alphatab/test/importer/MusicXmlImporter.test.ts b/packages/alphatab/test/importer/MusicXmlImporter.test.ts index 4f4664b78..7daa4228b 100644 --- a/packages/alphatab/test/importer/MusicXmlImporter.test.ts +++ b/packages/alphatab/test/importer/MusicXmlImporter.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { BendType } from '@coderline/alphatab/model/BendType'; +import { Fingers } from '@coderline/alphatab/model/Fingers'; import { JsonConverter } from '@coderline/alphatab/model/JsonConverter'; import { BarNumberDisplay } from '@coderline/alphatab/model/RenderStylesheet'; import type { Score } from '@coderline/alphatab/model/Score'; @@ -426,6 +427,81 @@ describe('MusicXmlImporterTests', () => { expect(midiFile.events.filter(e => Number.isNaN(e.tick))).toHaveLength(0); }); + it('fingering', async () => { + const score = await MusicXmlImporterTestHelper.loadFile('test-data/musicxml4/fingering.xml'); + // each part has every value once as (bars 0-5) and once as (bars 6-11) + const notes = (track: number, firstBar: number) => + score.tracks[track].staves[0].bars + .slice(firstBar, firstBar + 6) + .flatMap(b => b.voices[0].beats) + .map(b => b.notes[0]); + + const letters = [ + Fingers.Thumb, // p + Fingers.Thumb, // t + Fingers.Thumb, // T + Fingers.Thumb, // P + Fingers.IndexFinger, // i + Fingers.IndexFinger, // I + Fingers.MiddleFinger, // m + Fingers.MiddleFinger, // M + Fingers.AnnularFinger, // a + Fingers.AnnularFinger, // A + Fingers.LittleFinger, // c + Fingers.LittleFinger, // C + Fingers.LittleFinger, // e + Fingers.LittleFinger, // o + Fingers.LittleFinger, // q + Fingers.LittleFinger, // s + Fingers.LittleFinger, // x + Fingers.Unknown // 6 + ]; + // 0 1 2 3 4 5 + const keyboardDigits = [ + Fingers.NoOrDead, + Fingers.Thumb, + Fingers.IndexFinger, + Fingers.MiddleFinger, + Fingers.AnnularFinger, + Fingers.LittleFinger + ]; + const fretDigits = [ + Fingers.NoOrDead, + Fingers.IndexFinger, + Fingers.MiddleFinger, + Fingers.AnnularFinger, + Fingers.LittleFinger, + Fingers.Thumb + ]; + + function expectFingers(actual: Fingers[], digits: Fingers[]) { + expect(actual.slice(0, 6)).toEqual(digits); + expect(actual.slice(6)).toEqual(letters); + } + + // guitar + expect(score.tracks[0].playbackInfo.program).toBe(24); + expectFingers( + notes(0, 0).map(n => n.leftHandFinger), + fretDigits + ); + expectFingers( + notes(0, 6).map(n => n.rightHandFinger), + keyboardDigits + ); + + // piano + expect(score.tracks[1].playbackInfo.program).toBe(0); + expectFingers( + notes(1, 0).map(n => n.leftHandFinger), + keyboardDigits + ); + expectFingers( + notes(1, 6).map(n => n.rightHandFinger), + keyboardDigits + ); + }); + describe('barnumberdisplay', async () => { async function testPartwise(filename: string, display: BarNumberDisplay) { const score = await MusicXmlImporterTestHelper.loadFile(`test-data/musicxml4/${filename}`); From d834ca4198e98662b4755dabf717d8f7ec9ae298 Mon Sep 17 00:00:00 2001 From: danielku15 Date: Wed, 7 Oct 2026 12:11:04 +0200 Subject: [PATCH 2/3] fix(importer): read MusicXML fingering digits by instrument convention On non-piano parts digits now follow the fretting hand numbering (0 = open, 1 = index ... 4 = little, 5 = thumb) instead of the keyboard numbering, matching how the renderer displays fingers. Adds the remaining SMuFL fingering letters (T, e, o, q, s, x) and case-insensitive matching. Fixes #2826 --- .../alphatab/src/importer/MusicXmlImporter.ts | 48 +++++++++++++++---- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/packages/alphatab/src/importer/MusicXmlImporter.ts b/packages/alphatab/src/importer/MusicXmlImporter.ts index 110eb946a..779da2cd0 100644 --- a/packages/alphatab/src/importer/MusicXmlImporter.ts +++ b/packages/alphatab/src/importer/MusicXmlImporter.ts @@ -2,6 +2,7 @@ import { ScoreImporter } from '@coderline/alphatab/importer/ScoreImporter'; import { UnsupportedFormatError } from '@coderline/alphatab/importer/UnsupportedFormatError'; import { IOHelper } from '@coderline/alphatab/io/IOHelper'; import { Logger } from '@coderline/alphatab/Logger'; +import { GeneralMidi } from '@coderline/alphatab/midi/GeneralMidi'; import { MidiUtils } from '@coderline/alphatab/midi/MidiUtils'; import { AccentuationType } from '@coderline/alphatab/model/AccentuationType'; import { Automation, AutomationType } from '@coderline/alphatab/model/Automation'; @@ -3672,12 +3673,15 @@ export class MusicXmlImporter extends ScoreImporter { // case 'thumb-position': Not supported case 'fingering': if (note) { - note.leftHandFinger = this._parseFingering(c); + note.leftHandFinger = this._parseFingering( + c, + !GeneralMidi.isPiano(beat.voice.bar.staff.track.playbackInfo.program) + ); } break; case 'pluck': if (note) { - note.rightHandFinger = this._parseFingering(c); + note.rightHandFinger = this._parseFingering(c, false); } break; // case 'double-tongue': Not supported @@ -3788,25 +3792,53 @@ export class MusicXmlImporter extends ScoreImporter { } } - private _parseFingering(c: XmlNode): Fingers { - switch (c.innerText) { + /** + * Parses the text of a `` or `` element into a finger. + * @param c The element to parse. + * @param fretNumbering Whether digits follow the fretting hand numbering instead of the keyboard numbering. + * @remarks + * MusicXML defines the fingering as free text, "typically indicated 1,2,3,4,5", and leaves open which + * number means which finger (see also https://github.com/w3c-cg/musicxml/issues/438). + * The digits are therefore read in the convention of the instrument and hand: + * - Keyboards and the plucking hand (``): 1 = thumb … 5 = little finger. + * - Fretting hand on fretted and bowed instruments: 0 = open, 1 = index … 4 = little finger. 5 is mapped to the + * thumb as it is the only finger not covered by 1-4. + * + * The piano check used by the caller must match the one in `FingeringGroupGlyph.fingerToMusicFontSymbol`, + * so that the fingering is displayed as written in the file. + * Letters follow the SMuFL fingering vocabulary (T, t, p: thumb; i: index; m: middle; a: ring; + * c, e, o, q, s, x: little finger) and are matched case-insensitive. Text which cannot be mapped to a finger is + * ignored. + */ + private _parseFingering(c: XmlNode, fretNumbering: boolean): Fingers { + switch (c.innerText.toLowerCase()) { case '0': return Fingers.NoOrDead; case '1': + return fretNumbering ? Fingers.IndexFinger : Fingers.Thumb; + case '2': + return fretNumbering ? Fingers.MiddleFinger : Fingers.IndexFinger; + case '3': + return fretNumbering ? Fingers.AnnularFinger : Fingers.MiddleFinger; + case '4': + return fretNumbering ? Fingers.LittleFinger : Fingers.AnnularFinger; + case '5': + return fretNumbering ? Fingers.Thumb : Fingers.LittleFinger; case 'p': case 't': return Fingers.Thumb; - case '2': case 'i': return Fingers.IndexFinger; - case '3': case 'm': return Fingers.MiddleFinger; - case '4': case 'a': return Fingers.AnnularFinger; - case '5': case 'c': + case 'e': + case 'o': + case 'q': + case 's': + case 'x': return Fingers.LittleFinger; } From 711265016fe92d526014e2a6b8e5402c945c3bf1 Mon Sep 17 00:00:00 2001 From: danielku15 Date: Wed, 7 Oct 2026 12:18:55 +0200 Subject: [PATCH 3/3] test(importer): avoid slice and spread in fingering test for the Kotlin port --- .../test/importer/MusicXmlImporter.test.ts | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/alphatab/test/importer/MusicXmlImporter.test.ts b/packages/alphatab/test/importer/MusicXmlImporter.test.ts index 7daa4228b..07f97ec4e 100644 --- a/packages/alphatab/test/importer/MusicXmlImporter.test.ts +++ b/packages/alphatab/test/importer/MusicXmlImporter.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { BendType } from '@coderline/alphatab/model/BendType'; import { Fingers } from '@coderline/alphatab/model/Fingers'; +import type { Note } from '@coderline/alphatab/model/Note'; import { JsonConverter } from '@coderline/alphatab/model/JsonConverter'; import { BarNumberDisplay } from '@coderline/alphatab/model/RenderStylesheet'; import type { Score } from '@coderline/alphatab/model/Score'; @@ -430,11 +431,15 @@ describe('MusicXmlImporterTests', () => { it('fingering', async () => { const score = await MusicXmlImporterTestHelper.loadFile('test-data/musicxml4/fingering.xml'); // each part has every value once as (bars 0-5) and once as (bars 6-11) - const notes = (track: number, firstBar: number) => - score.tracks[track].staves[0].bars - .slice(firstBar, firstBar + 6) - .flatMap(b => b.voices[0].beats) - .map(b => b.notes[0]); + const notes = (track: number, firstBar: number): Note[] => { + const result: Note[] = []; + for (let i = firstBar; i < firstBar + 6; i++) { + for (const b of score.tracks[track].staves[0].bars[i].voices[0].beats) { + result.push(b.notes[0]); + } + } + return result; + }; const letters = [ Fingers.Thumb, // p @@ -475,8 +480,14 @@ describe('MusicXmlImporterTests', () => { ]; function expectFingers(actual: Fingers[], digits: Fingers[]) { - expect(actual.slice(0, 6)).toEqual(digits); - expect(actual.slice(6)).toEqual(letters); + const expected: Fingers[] = []; + for (const f of digits) { + expected.push(f); + } + for (const f of letters) { + expected.push(f); + } + expect(actual).toEqual(expected); } // guitar