From aafb63ad2955282759813c2a60eff99c31938a28 Mon Sep 17 00:00:00 2001 From: Stanislav Gabenov Date: Mon, 9 Nov 2026 01:43:05 +0300 Subject: [PATCH 1/2] Preserve per-note Guitar Pro velocity metadata --- packages/alphatab/src/exporter/GpifWriter.ts | 9 +++++++++ .../alphatab/src/generated/SerializerSchema.ts | 2 +- .../alphatab/src/generated/model/NoteCloner.ts | 2 ++ .../src/generated/model/NoteSerializer.ts | 16 ++++++++++++---- packages/alphatab/src/importer/GpifParser.ts | 6 ++++++ packages/alphatab/src/model/Note.ts | 15 +++++++++++++++ .../alphatab/test/exporter/Gp7Exporter.test.ts | 13 +++++++++++++ .../alphatab/test/generated/SerializerFill.ts | 2 ++ website/docs/releases/release1_10.mdx | 4 ++++ 9 files changed, 64 insertions(+), 5 deletions(-) diff --git a/packages/alphatab/src/exporter/GpifWriter.ts b/packages/alphatab/src/exporter/GpifWriter.ts index f8e4ab85d..47b458bd0 100644 --- a/packages/alphatab/src/exporter/GpifWriter.ts +++ b/packages/alphatab/src/exporter/GpifWriter.ts @@ -211,6 +211,15 @@ export class GpifWriter { this._writeNoteProperties(noteNode, note); + const gpVelocity = note.gpVelocity; + if (gpVelocity !== null) { + noteNode.addElement('Velocity').innerText = gpVelocity.toString(); + } + const gpRelativeVelocity = note.gpRelativeVelocity; + if (gpRelativeVelocity !== null) { + noteNode.addElement('RelativeVelocity').innerText = gpRelativeVelocity.toString(); + } + if (note.isGhost) { noteNode.addElement('AntiAccent').innerText = 'normal'; } diff --git a/packages/alphatab/src/generated/SerializerSchema.ts b/packages/alphatab/src/generated/SerializerSchema.ts index 1da7047e5..d88384fc2 100644 --- a/packages/alphatab/src/generated/SerializerSchema.ts +++ b/packages/alphatab/src/generated/SerializerSchema.ts @@ -12,5 +12,5 @@ export class SerializerSchema { /** * A hash of the field ids, keys and types of all serialized classes (50 classes). */ - public static readonly fingerprint: number = 0x4671941c; + public static readonly fingerprint: number = 0xc4a8feb8; } diff --git a/packages/alphatab/src/generated/model/NoteCloner.ts b/packages/alphatab/src/generated/model/NoteCloner.ts index b1ede919d..17f439396 100644 --- a/packages/alphatab/src/generated/model/NoteCloner.ts +++ b/packages/alphatab/src/generated/model/NoteCloner.ts @@ -51,6 +51,8 @@ export class NoteCloner { clone.durationPercent = original.durationPercent; clone.accidentalMode = original.accidentalMode; clone.dynamics = original.dynamics; + clone.gpVelocity = original.gpVelocity; + clone.gpRelativeVelocity = original.gpRelativeVelocity; clone.ornament = original.ornament; return clone; } diff --git a/packages/alphatab/src/generated/model/NoteSerializer.ts b/packages/alphatab/src/generated/model/NoteSerializer.ts index c18cf97e5..17eb1658a 100644 --- a/packages/alphatab/src/generated/model/NoteSerializer.ts +++ b/packages/alphatab/src/generated/model/NoteSerializer.ts @@ -28,7 +28,7 @@ import type { IObjectWriter } from "@coderline/alphatab/io/IObjectWriter"; * @internal */ export class NoteSerializer { - private static readonly _fields: RecordFields = new RecordFields(["id", "accentuated", "bendtype", "bendstyle", "iscontinuedbend", "bendpoints", "fret", "string", "showstringnumber", "octave", "tone", "percussionarticulation", "isvisible", "islefthandtapped", "ishammerpullorigin", "isslurdestination", "harmonictype", "harmonicvalue", "isghost", "isletring", "ispalmmute", "isdead", "isstaccato", "slideintype", "slideouttype", "vibrato", "istiedestination", "lefthandfinger", "righthandfinger", "trillvalue", "trillspeed", "istrillstart", "durationpercent", "accidentalmode", "dynamics", "ornament", "style"]); + private static readonly _fields: RecordFields = new RecordFields(["id", "accentuated", "bendtype", "bendstyle", "iscontinuedbend", "bendpoints", "fret", "string", "showstringnumber", "octave", "tone", "percussionarticulation", "isvisible", "islefthandtapped", "ishammerpullorigin", "isslurdestination", "harmonictype", "harmonicvalue", "isghost", "isletring", "ispalmmute", "isdead", "isstaccato", "slideintype", "slideouttype", "vibrato", "istiedestination", "lefthandfinger", "righthandfinger", "trillvalue", "trillspeed", "istrillstart", "durationpercent", "accidentalmode", "dynamics", "gpvelocity", "gprelativevelocity", "ornament", "style"]); public static read(obj: Note, reader: IObjectReader): void { reader.readRecord(NoteSerializer._fields, (id, k, r) => { switch (id) { @@ -138,9 +138,15 @@ export class NoteSerializer { obj.dynamics = JsonHelper.parseEnum(r.readValue(), DynamicValue)!; return true; case 35: - obj.ornament = JsonHelper.parseEnum(r.readValue(), NoteOrnament)!; + obj.gpVelocity = r.readValue() as number | null; return true; case 36: + obj.gpRelativeVelocity = r.readValue() as number | null; + return true; + case 37: + obj.ornament = JsonHelper.parseEnum(r.readValue(), NoteOrnament)!; + return true; + case 38: if (r.isNullish()) { obj.style = undefined; } @@ -221,9 +227,11 @@ export class NoteSerializer { w.numberField(32, obj.durationPercent); w.numberField(33, obj.accidentalMode as number); w.numberField(34, obj.dynamics as number); - w.numberField(35, obj.ornament as number); + w.valueField(35, obj.gpVelocity); + w.valueField(36, obj.gpRelativeVelocity); + w.numberField(37, obj.ornament as number); if (obj.style) { - w.field(36); + w.field(38); NoteStyleSerializer.write(obj.style, w); } obj.toJson(w); diff --git a/packages/alphatab/src/importer/GpifParser.ts b/packages/alphatab/src/importer/GpifParser.ts index 11de2f65d..ec618dd92 100644 --- a/packages/alphatab/src/importer/GpifParser.ts +++ b/packages/alphatab/src/importer/GpifParser.ts @@ -2273,6 +2273,12 @@ export class GpifParser { case 'Properties': this._parseNoteProperties(c, note, noteId); break; + case 'Velocity': + note.gpVelocity = GpifParser._parseIntSafe(c.innerText, 0); + break; + case 'RelativeVelocity': + note.gpRelativeVelocity = GpifParser._parseFloatSafe(c.innerText, 0); + break; case 'AntiAccent': if (c.innerText.toLowerCase() === 'normal') { note.isGhost = true; diff --git a/packages/alphatab/src/model/Note.ts b/packages/alphatab/src/model/Note.ts index 5a84b792d..8fb377d1c 100644 --- a/packages/alphatab/src/model/Note.ts +++ b/packages/alphatab/src/model/Note.ts @@ -591,6 +591,21 @@ export class Note { */ public dynamics: DynamicValue = DynamicValue.F; + /** + * Guitar Pro's optional per-note MIDI velocity field. Preserved on GP import + * and export; alphaTab playback currently uses {@link dynamics} instead. + * @since 1.10.0 + */ + public gpVelocity: number | null = null; + + /** + * Guitar Pro's optional per-note relative velocity field in decibels. + * Preserved on GP import and export; not currently applied to playback. + * @since 1.10.0 + */ + public gpRelativeVelocity: number | null = null; + + /** * @clone_ignore * @json_ignore diff --git a/packages/alphatab/test/exporter/Gp7Exporter.test.ts b/packages/alphatab/test/exporter/Gp7Exporter.test.ts index 9b6a42c29..65e5999a2 100644 --- a/packages/alphatab/test/exporter/Gp7Exporter.test.ts +++ b/packages/alphatab/test/exporter/Gp7Exporter.test.ts @@ -23,6 +23,19 @@ import { TestPlatform } from 'test/TestPlatform'; import { describe, expect, it } from 'vitest'; describe('Gp7ExporterTest', () => { + it('preserves per-note GP velocity metadata through repeated round trips', () => { + for (const relative of [2.75, -1.5]) { + const score = ScoreLoader.loadAlphaTex('3.3.4'); + const note = score.tracks[0].staves[0].bars[0].voices[0].beats[0].notes[0]; + note.gpVelocity = 100; + note.gpRelativeVelocity = relative; + const imported = prepareImporterWithBytes(exportGp7(score)).readScore(); + const roundTripped = prepareImporterWithBytes(exportGp7(imported)).readScore(); + const result = roundTripped.tracks[0].staves[0].bars[0].voices[0].beats[0].notes[0]; + expect(result.gpVelocity).toBe(100); + expect(result.gpRelativeVelocity).toBe(relative); + } + }); async function loadScore(name: string): Promise { try { const data = await TestPlatform.loadFile(`test-data/${name}`); diff --git a/packages/alphatab/test/generated/SerializerFill.ts b/packages/alphatab/test/generated/SerializerFill.ts index 1b6e4b416..11da39950 100644 --- a/packages/alphatab/test/generated/SerializerFill.ts +++ b/packages/alphatab/test/generated/SerializerFill.ts @@ -999,6 +999,8 @@ export class SerializerFill { obj.durationPercent = 7.25; obj.accidentalMode = SerializerFill.otherEnumValue(NoteAccidentalMode, obj.accidentalMode) as NoteAccidentalMode; obj.dynamics = SerializerFill.otherEnumValue(DynamicValue, obj.dynamics) as DynamicValue; + obj.gpVelocity = 7.25; + obj.gpRelativeVelocity = 7.25; obj.ornament = SerializerFill.otherEnumValue(NoteOrnament, obj.ornament) as NoteOrnament; { const v0 = new NoteStyle(); diff --git a/website/docs/releases/release1_10.mdx b/website/docs/releases/release1_10.mdx index 06ddea0de..d17467f90 100644 --- a/website/docs/releases/release1_10.mdx +++ b/website/docs/releases/release1_10.mdx @@ -6,6 +6,10 @@ import { AlphaTab } from '@site/src/components/AlphaTab'; ## 1.10.0 +#### Guitar Pro: preserve per-note velocity metadata + +The Guitar Pro importer and exporter preserve optional per-note absolute and relative velocity metadata. alphaTab playback continues to use note dynamics. + ### Music Notation #### render: transposed tabs on playable strings From 10b43a9438f59271c65ef8e76f18503845e3a763 Mon Sep 17 00:00:00 2001 From: Stanislav Gabenov Date: Mon, 9 Nov 2026 01:45:23 +0300 Subject: [PATCH 2/2] Link velocity export release note to pull request --- website/docs/releases/release1_10.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/website/docs/releases/release1_10.mdx b/website/docs/releases/release1_10.mdx index d17467f90..bc46d61d8 100644 --- a/website/docs/releases/release1_10.mdx +++ b/website/docs/releases/release1_10.mdx @@ -7,6 +7,7 @@ import { AlphaTab } from '@site/src/components/AlphaTab'; ## 1.10.0 #### Guitar Pro: preserve per-note velocity metadata +https://github.com/CoderLine/alphaTab/pull/3022 The Guitar Pro importer and exporter preserve optional per-note absolute and relative velocity metadata. alphaTab playback continues to use note dynamics.