diff --git a/packages/alphatab/src/importer/MusicXmlImporter.ts b/packages/alphatab/src/importer/MusicXmlImporter.ts index 169b46420..c4fb1090c 100644 --- a/packages/alphatab/src/importer/MusicXmlImporter.ts +++ b/packages/alphatab/src/importer/MusicXmlImporter.ts @@ -28,6 +28,7 @@ import { FermataType, Fermata } from '@coderline/alphatab/model/Fermata'; import { Fingers } from '@coderline/alphatab/model/Fingers'; import { GolpeType } from '@coderline/alphatab/model/GolpeType'; import { GraceType } from '@coderline/alphatab/model/GraceType'; +import { HarmonicType } from '@coderline/alphatab/model/HarmonicType'; import { InstrumentArticulation } from '@coderline/alphatab/model/InstrumentArticulation'; import { KeySignature } from '@coderline/alphatab/model/KeySignature'; import { KeySignatureType } from '@coderline/alphatab/model/KeySignatureType'; @@ -56,6 +57,42 @@ import type { XmlNode } from '@coderline/alphatab/xml/XmlNode'; import type { ZipEntry } from '@coderline/alphatab/zip/ZipEntry'; import { ZipReader } from '@coderline/alphatab/zip/ZipReader'; +/** + * The pitch a note of a MusicXML harmonic describes. + * @internal + */ +enum MusicXmlHarmonicPitch { + None = 0, + Base = 1, + Touching = 2, + Sounding = 3 +} + +/** + * @internal + * @record + */ +interface MusicXmlHarmonic { + type: HarmonicType; + pitch: MusicXmlHarmonicPitch; + /** + * Groups the notes of one harmonic when a chord holds several of them (number-level, MusicXML 4.1). + */ + group: string; +} + +/** + * The note which represents a MusicXML harmonic in the current beat. + * @internal + * @record + */ +interface MusicXmlHarmonicGroup { + note: Note; + type: HarmonicType; + isBasePitch: boolean; + hasTouchingPitch: boolean; +} + /** * @internal */ @@ -1038,6 +1075,11 @@ export class MusicXmlImporter extends ScoreImporter { */ private _lastBeat: Beat | null = null; + /** + * The harmonic groups of the last created beat, by their group number. + */ + private _harmonicGroups = new Map(); + private _parsePartMeasure( element: XmlNode, masterBar: MasterBar, @@ -2600,6 +2642,11 @@ export class MusicXmlImporter extends ScoreImporter { isChord = false; } + const harmonic = note !== null ? MusicXmlImporter._readHarmonic(element) : null; + if (isChord && harmonic !== null && this._foldHarmonicNote(note!, harmonic, track, staffIndex)) { + return; + } + // the stem direction relates to the written pitch, hence remember it before the staff transposition is applied const writtenNoteValue = note !== null ? this._calculatePitchedNoteValue(note) : 0; @@ -2615,6 +2662,14 @@ export class MusicXmlImporter extends ScoreImporter { if (note !== null) { note.isVisible = element.getAttribute('print-object', 'yes') !== 'no'; this._resolveAttachedNote(note, instrumentId, isPitched); + if (harmonic !== null && harmonic.pitch !== MusicXmlHarmonicPitch.None) { + this._harmonicGroups.set(harmonic.group, { + note, + type: harmonic.type, + isBasePitch: harmonic.pitch === MusicXmlHarmonicPitch.Base, + hasTouchingPitch: false + }); + } } // Pass 2: interpret all other children with the note attached @@ -2787,7 +2842,117 @@ export class MusicXmlImporter extends ScoreImporter { if (note !== null) { // is only known after pass 2 this._finalizeStringNumber(note); + // natural harmonics are touched at the fret, artificial harmonics need the touching or sounding pitch of a following chord note + if ( + harmonic !== null && + harmonic.type === HarmonicType.Natural && + (!note.isStringed || ModelUtils.harmonicValueToPitch(note.fret) > 0) + ) { + note.harmonicType = HarmonicType.Natural; + note.harmonicValue = ModelUtils.deltaFretToHarmonicValue(note.fret); + } + } + } + + /** + * Reads the `` of the note. + * A harmonic without `` or `` only shows the harmonic circle, like Guitar Pro 8 we ignore it + * (it exports pinch, tap and semi harmonics like this). + */ + private static _readHarmonic(element: XmlNode): MusicXmlHarmonic | null { + for (const notations of element.childElements()) { + if (notations.localName !== 'notations') { + continue; + } + for (const technical of notations.childElements()) { + if (technical.localName !== 'technical') { + continue; + } + for (const c of technical.childElements()) { + if (c.localName !== 'harmonic') { + continue; + } + const harmonic: MusicXmlHarmonic = { + type: HarmonicType.None, + pitch: MusicXmlHarmonicPitch.None, + group: c.getAttribute('number', '1') + }; + for (const h of c.childElements()) { + switch (h.localName) { + case 'natural': + harmonic.type = HarmonicType.Natural; + break; + case 'artificial': + harmonic.type = HarmonicType.Artificial; + break; + case 'base-pitch': + harmonic.pitch = MusicXmlHarmonicPitch.Base; + break; + case 'touching-pitch': + harmonic.pitch = MusicXmlHarmonicPitch.Touching; + break; + case 'sounding-pitch': + harmonic.pitch = MusicXmlHarmonicPitch.Sounding; + break; + } + } + return harmonic.type !== HarmonicType.None ? harmonic : null; + } + } } + return null; + } + + /** + * MusicXML writes a harmonic as separate chord notes for the base pitch (the played note), + * the touching pitch (where the string is touched) and the sounding pitch (what is heard). + * In alphaTab a harmonic is one note: the touching and sounding notes are folded into the note + * of their harmonic (which they follow in all known files: the MusicXML examples, the LilyPond test suite, Guitar Pro 8) + * and only define its harmonic value. A base pitch note starts a new harmonic. + * The touching pitch defines the harmonic more precisely than the sounding pitch (e.g. fret 7 and 19 sound the same), + * Guitar Pro 8 writes both consistently. + * The pitch is relative to the staff the note specifies, Guitar Pro 8 omits the `` on these chord notes + * of a tablature staff and writes them in the pitch of the first staff. + * @returns true if the note was folded and must not be added to the beat. + */ + private _foldHarmonicNote(note: Note, harmonic: MusicXmlHarmonic, track: Track, staffIndex: number): boolean { + const group = this._harmonicGroups.get(harmonic.group); + if ( + group === undefined || + harmonic.pitch === MusicXmlHarmonicPitch.Base || + harmonic.pitch === MusicXmlHarmonicPitch.None + ) { + return false; + } + + // only the base pitch is a reference for the harmonic value + const played = group.note; + if (group.isBasePitch && !group.hasTouchingPitch) { + const transpose = this._getStaffContext(this._getOrCreateStaff(track, staffIndex)).transpose; + let interval = this._calculatePitchedNoteValue(note) + transpose - this._calculatePitchedNoteValue(played); + // natural harmonics are touched relative to the open string + if (played.isStringed && group.type === HarmonicType.Natural) { + interval += played.fret; + } + + if (harmonic.pitch === MusicXmlHarmonicPitch.Touching) { + played.harmonicType = group.type; + played.harmonicValue = ModelUtils.deltaFretToHarmonicValue(interval); + group.hasTouchingPitch = true; + } else { + // the lowest fret sounding the interval + for (let fret = 1; fret <= 24; fret++) { + const value = ModelUtils.deltaFretToHarmonicValue(fret); + if (ModelUtils.harmonicValueToPitch(value) === interval) { + played.harmonicType = group.type; + played.harmonicValue = value; + break; + } + } + } + } + + return true; } /** @@ -2850,6 +3015,7 @@ export class MusicXmlImporter extends ScoreImporter { const newBeat = new Beat(); this._beats.push(newBeat); + this._harmonicGroups.clear(); newBeat.isEmpty = false; newBeat.dynamics = this._currentDynamics; @@ -3664,8 +3830,7 @@ export class MusicXmlImporter extends ScoreImporter { case 'down-bow': beat.pickStroke = PickStroke.Down; break; - case 'harmonic': - break; + // case 'harmonic': handled in _parseNote // case 'open-string': Not supported // case 'thumb-position': Not supported case 'fingering': diff --git a/packages/alphatab/src/model/ModelUtils.ts b/packages/alphatab/src/model/ModelUtils.ts index afba033fa..08087ea13 100644 --- a/packages/alphatab/src/model/ModelUtils.ts +++ b/packages/alphatab/src/model/ModelUtils.ts @@ -395,6 +395,87 @@ export class ModelUtils { } } + /** + * Gets the interval in semitones a harmonic sounds above the played pitch when touching the string + * at the given harmonic value (the fret position of the node). + * @param value The harmonic value. + */ + public static harmonicValueToPitch(value: number): number { + // add semitones to reach corresponding harmonic frets + if (ModelUtils.isAlmostEqualTo(value, 2.4)) { + return 36; + } + if (ModelUtils.isAlmostEqualTo(value, 2.7)) { + // Fret 3 2nd octave + minor seventh + return 34; + } + if (value < 3) { + // no natural harmonics below fret 3 + return 0; + } + if (value <= 3.5) { + // Fret 3 2nd octave + fifth + return 31; + } + if (value <= 4) { + return 28; + } + if (value <= 5) { + return 24; + } + if (value <= 6) { + return 34; + } + if (value <= 7) { + return 19; + } + if (value <= 8.5) { + return 36; + } + if (value <= 9) { + return 28; + } + if (value <= 10) { + return 34; + } + if (value <= 11) { + return 0; + } + if (value <= 12) { + return 12; + } + if (value < 14) { + // fret 13,14 stay + return 0; + } + if (value <= 15) { + return 34; + } + if (value <= 16) { + return 28; + } + if (value <= 17) { + return 36; + } + if (value <= 18) { + return 0; + } + if (value <= 19) { + return 19; + } + if (value <= 21) { + // 20,21 stay + return 0; + } + if (value <= 22) { + return 36; + } + if (value <= 24) { + return 24; + } + return 0; + } + public static clamp(value: number, min: number, max: number): number { if (value <= min) { return min; diff --git a/packages/alphatab/src/model/Note.ts b/packages/alphatab/src/model/Note.ts index df7ab9340..8d524ce70 100644 --- a/packages/alphatab/src/model/Note.ts +++ b/packages/alphatab/src/model/Note.ts @@ -684,15 +684,11 @@ export class Note { const transpositionPitch = applyTranspositionPitch ? this.beat.voice.bar.staff.transpositionPitch : 0; if (applyHarmonic) { - let realValue = this.calculateRealValue(applyTranspositionPitch, false); - if (this.isStringed) { - if (this.harmonicType === HarmonicType.Natural) { - realValue = this.harmonicPitch + this.stringTuning - transpositionPitch; - } else { - realValue += this.harmonicPitch; - } + const realValue = this.calculateRealValue(applyTranspositionPitch, false); + if (this.isStringed && this.harmonicType === HarmonicType.Natural) { + return this.harmonicPitch + this.stringTuning - transpositionPitch; } - return realValue; + return realValue + this.harmonicPitch; } if (this.isPercussion) { @@ -708,84 +704,16 @@ export class Note { return 0; } + /** + * The interval in semitones the harmonic sounds above the played pitch. Natural harmonics are relative to the open string + * and therefore need a string, the other types are an interval above the played note on any pitched note. + */ public get harmonicPitch(): number { - if (this.harmonicType === HarmonicType.None || !this.isStringed) { - return 0; - } - const value: number = this.harmonicValue; - // add semitones to reach corresponding harmonic frets - if (ModelUtils.isAlmostEqualTo(value, 2.4)) { - return 36; - } - if (ModelUtils.isAlmostEqualTo(value, 2.7)) { - // Fret 3 2nd octave + minor seventh - return 34; - } - if (value < 3) { - // no natural harmonics below fret 3 - return 0; - } - if (value <= 3.5) { - // Fret 3 2nd octave + fifth - return 31; - } - if (value <= 4) { - return 28; - } - if (value <= 5) { - return 24; - } - if (value <= 6) { - return 34; - } - if (value <= 7) { - return 19; - } - if (value <= 8.5) { - return 36; - } - if (value <= 9) { - return 28; - } - if (value <= 10) { - return 34; - } - if (value <= 11) { - return 0; - } - if (value <= 12) { - return 12; - } - if (value < 14) { - // fret 13,14 stay - return 0; - } - if (value <= 15) { - return 34; - } - if (value <= 16) { - return 28; - } - if (value <= 17) { - return 36; - } - if (value <= 18) { - return 0; - } - if (value <= 19) { - return 19; - } - if (value <= 21) { - // 20,21 stay + const hasHarmonicPitch = this.isStringed || (this.isPiano && this.harmonicType !== HarmonicType.Natural); + if (this.harmonicType === HarmonicType.None || !hasHarmonicPitch) { return 0; } - if (value <= 22) { - return 36; - } - if (value <= 24) { - return 24; - } - return 0; + return ModelUtils.harmonicValueToPitch(this.harmonicValue); } public get initialBendValue(): number { diff --git a/packages/alphatab/test-data/musicxml-testsuite/32a-Notations.png b/packages/alphatab/test-data/musicxml-testsuite/32a-Notations.png index f23a3d5e4..0d1ce4a1e 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/32a-Notations.png and b/packages/alphatab/test-data/musicxml-testsuite/32a-Notations.png differ diff --git a/packages/alphatab/test/importer/AlphaTexImporter.test.ts b/packages/alphatab/test/importer/AlphaTexImporter.test.ts index 27eea8bb0..c37fd57fa 100644 --- a/packages/alphatab/test/importer/AlphaTexImporter.test.ts +++ b/packages/alphatab/test/importer/AlphaTexImporter.test.ts @@ -312,6 +312,15 @@ describe('AlphaTexImporterTest', () => { expect(score.tracks[0].staves[0].bars[0].voices[0].beats[4].notes[0].harmonicType).toBe(HarmonicType.Semi); }); + it('harmonics-pitched', () => { + // without string the interval of the non-natural harmonics applies to the pitch, natural ones need the open string + const notes = parseTex('C4{ah 12} C4{ah 7} C4{nh}').tracks[0].staves[0].bars[0].voices[0].beats.map( + b => b.notes[0] + ); + expect(notes.map(n => n.realValue)).toEqual([72, 79, 60]); + expect(notes.map(n => n.displayValue)).toEqual([60, 60, 60]); + }); + it('hamonics-rendering-text-issue79', async () => { const tex: string = ':8 3.3{nh} 3.3{ah} 3.3{th} 3.3{ph} 3.3{sh}'; const score = parseTex(tex); @@ -329,25 +338,13 @@ describe('AlphaTexImporterTest', () => { }); renderer.renderScore(score, [0]); const regexTemplate: string = ']+>\\s*{0}\\s*'; + expect(new RegExp(regexTemplate.replace('{0}', harmonicToString(HarmonicType.Natural))).exec(svg)).toBeTruthy(); expect( - new RegExp(regexTemplate.replace('{0}', harmonicToString(HarmonicType.Natural))).exec( - svg - ) - ).toBeTruthy(); - expect( - new RegExp( - regexTemplate.replace('{0}', harmonicToString(HarmonicType.Artificial)) - ).exec(svg) - ).toBeTruthy(); - expect( - new RegExp(regexTemplate.replace('{0}', harmonicToString(HarmonicType.Tap))).exec(svg) - ).toBeTruthy(); - expect( - new RegExp(regexTemplate.replace('{0}', harmonicToString(HarmonicType.Pinch))).exec(svg) - ).toBeTruthy(); - expect( - new RegExp(regexTemplate.replace('{0}', harmonicToString(HarmonicType.Semi))).exec(svg) + new RegExp(regexTemplate.replace('{0}', harmonicToString(HarmonicType.Artificial))).exec(svg) ).toBeTruthy(); + expect(new RegExp(regexTemplate.replace('{0}', harmonicToString(HarmonicType.Tap))).exec(svg)).toBeTruthy(); + expect(new RegExp(regexTemplate.replace('{0}', harmonicToString(HarmonicType.Pinch))).exec(svg)).toBeTruthy(); + expect(new RegExp(regexTemplate.replace('{0}', harmonicToString(HarmonicType.Semi))).exec(svg)).toBeTruthy(); }); it('grace-issue79', () => { diff --git a/packages/alphatab/test/importer/MusicXmlImporter.test.ts b/packages/alphatab/test/importer/MusicXmlImporter.test.ts index b6c81bcda..e908b4743 100644 --- a/packages/alphatab/test/importer/MusicXmlImporter.test.ts +++ b/packages/alphatab/test/importer/MusicXmlImporter.test.ts @@ -8,6 +8,7 @@ import { BendType } from '@coderline/alphatab/model/BendType'; import { CrescendoType } from '@coderline/alphatab/model/CrescendoType'; import { Direction } from '@coderline/alphatab/model/Direction'; import { Fingers } from '@coderline/alphatab/model/Fingers'; +import { HarmonicType } from '@coderline/alphatab/model/HarmonicType'; import { JsonConverter } from '@coderline/alphatab/model/JsonConverter'; import { ModelUtils } from '@coderline/alphatab/model/ModelUtils'; import { Ottavia } from '@coderline/alphatab/model/Ottavia'; @@ -595,6 +596,100 @@ describe('MusicXmlImporterTests', () => { expect(notes[3].isHammerPullDestination).toBe(false); }); + it('harmonics', () => { + const harmonic = (type: string, pitch: string) => `<${type}/>${pitch}`; + const tab = (string: number, fret: number) => `${string}${fret}`; + // chord notes without (like Guitar Pro 8 writes them) belong to the first staff + const note = (chord: string, step: string, octave: number, staff: string, technical: string) => + `${chord}${step}${octave}4` + + `1whole${staff}${technical}`; + const chord = (step: string, octave: number, type: string, pitch: string) => + note('', step, octave, '', harmonic(type, pitch)); + const tuning = (line: number, step: string, octave: number) => + `${step}${octave}`; + const tabStaff = (staff: string) => + `TAB56` + + `${tuning(1, 'E', 2)}${tuning(2, 'A', 2)}${tuning(3, 'D', 3)}${tuning(4, 'G', 3)}${tuning(5, 'B', 3)}${tuning(6, 'E', 4)}`; + const measure = (n: number, attributes: string, notes: string) => + `${attributes ? `1${attributes}` : ''}${notes}`; + + const xml = ` + + GP8Guitar + ${measure( + 1, + `2G2${tabStaff(' number="2"')}` + + '00-1', + // Guitar Pro 8 writes the touching and sounding pitch of both staves in the (octave transposed) pitch of the first staff + note('', 'C', 5, '1', harmonic('artificial', '')) + + chord('G', 5, 'artificial', '') + + chord('G', 6, 'artificial', '') + + '4' + + note('', 'C', 4, '2', harmonic('artificial', '') + tab(2, 1)) + + chord('G', 5, 'artificial', '') + + chord('G', 6, 'artificial', '') + )} + ${[ + // harmonic circle only (Guitar Pro 8 writes pinch, tap and semi harmonics like this) + note('', 'G', 4, '', `${tab(3, 12)}`), + note('', 'G', 4, '', harmonic('natural', '') + tab(3, 12)), + // no natural harmonic at fret 1 + note('', 'C', 4, '', harmonic('natural', '') + tab(2, 1)), + note('', 'D', 4, '', harmonic('natural', '') + tab(3, 7)), + // artificial harmonic without touching or sounding pitch + note('', 'C', 4, '', harmonic('artificial', '') + tab(2, 1)), + note('', 'C', 4, '', harmonic('artificial', '') + tab(2, 1)) + + chord('F', 4, 'artificial', ''), + note('', 'C', 4, '', harmonic('artificial', '') + tab(2, 1)) + + chord('G', 5, 'artificial', ''), + // natural harmonic on the open string touched at fret 7 + note('', 'G', 3, '', harmonic('natural', '') + tab(3, 0)) + + chord('D', 4, 'natural', '') + + chord('D', 5, 'natural', ''), + // a base pitch starts a new harmonic in the chord + note('', 'C', 4, '', harmonic('artificial', '') + tab(2, 1)) + + chord('C', 5, 'artificial', '') + + note('', 'E', 4, '', tab(1, 0)) + + note('', 'A', 3, '', harmonic('artificial', '') + tab(3, 2)) + + chord('E', 4, 'artificial', '') + ] + .map((content, i) => measure(i + 1, i === 0 ? tabStaff('') : '', content)) + .join('')} +`; + const score = MusicXmlImporterTestHelper.prepareImporterWithBytes(IOHelper.stringToBytes(xml)).readScore(); + + // notation and tablature staff of Guitar Pro 8 sound the same harmonic + for (const staff of score.tracks[0].staves) { + const staffNotes = staff.bars[0].voices[0].beats[0].notes; + expect(staffNotes.length).toBe(1); + expect(staffNotes[0].harmonicType).toBe(HarmonicType.Artificial); + expect(staffNotes[0].harmonicValue).toBe(7); + expect(staffNotes[0].realValue).toBe(79); + } + + const notes: Note[] = []; + for (const bar of score.tracks[1].staves[0].bars) { + for (const n of bar.voices[0].beats[0].notes) { + notes.push(n); + } + } + expect(notes.map(n => n.harmonicType)).toEqual([ + HarmonicType.None, + HarmonicType.Natural, + HarmonicType.None, + HarmonicType.Natural, + HarmonicType.None, + HarmonicType.Artificial, + HarmonicType.Artificial, + HarmonicType.Natural, + HarmonicType.Artificial, + HarmonicType.None, + HarmonicType.Artificial + ]); + expect(notes.map(n => n.harmonicValue)).toEqual([0, 12, 0, 7, 0, 5, 7, 7, 12, 0, 7]); + expect(notes.map(n => n.realValue)).toEqual([67, 67, 60, 74, 60, 84, 79, 74, 72, 64, 76]); + }); + describe('barnumberdisplay', async () => { async function testPartwise(filename: string, display: BarNumberDisplay) { const score = await MusicXmlImporterTestHelper.loadFile(`test-data/musicxml4/${filename}`);