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; } 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..07f97ec4e 100644 --- a/packages/alphatab/test/importer/MusicXmlImporter.test.ts +++ b/packages/alphatab/test/importer/MusicXmlImporter.test.ts @@ -1,5 +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'; @@ -426,6 +428,91 @@ 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): 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 + 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[]) { + const expected: Fingers[] = []; + for (const f of digits) { + expected.push(f); + } + for (const f of letters) { + expected.push(f); + } + expect(actual).toEqual(expected); + } + + // 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}`);