From 6901cd30b91265af8a28ad6ccefc95282e6514db Mon Sep 17 00:00:00 2001 From: Rafael Sales Date: Mon, 23 Mar 2026 00:08:59 -0300 Subject: [PATCH 1/2] feat(tab): add H/P text labels to hammer-on and pull-off arcs In the Tab renderer, the arc connecting hammer-on and pull-off notes is now annotated with an "H" (ascending fret = hammer-on) or "P" (descending fret = pull-off) label above the arc midpoint. The label is drawn via an overridden paint() in TabSlurGlyph, reusing the same canvas.fillText path already used for whammy/bend slurText. TieGlyph's coordinate fields (_startX/Y, _endX/Y, _tieHeight, _shouldPaint) are widened from private to protected to allow the subclass to read them during paint. Fixes #2608 --- .../rendering/glyphs/TabBeatContainerGlyph.ts | 7 ++++- .../src/rendering/glyphs/TabSlurGlyph.ts | 8 +++++- .../alphatab/src/rendering/glyphs/TieGlyph.ts | 26 +++++++++++++++++-- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/packages/alphatab/src/rendering/glyphs/TabBeatContainerGlyph.ts b/packages/alphatab/src/rendering/glyphs/TabBeatContainerGlyph.ts index 437899ea0..2c5d9fd41 100644 --- a/packages/alphatab/src/rendering/glyphs/TabBeatContainerGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/TabBeatContainerGlyph.ts @@ -66,12 +66,17 @@ export class TabBeatContainerGlyph extends BeatContainerGlyph { } } if (!expanded) { + let slurText: string | undefined = undefined; + if (n.isHammerPullOrigin && n.hammerPullDestination) { + slurText = n.hammerPullDestination.fret >= n.fret ? 'H' : 'P'; + } const effectSlur: TabSlurGlyph = new TabSlurGlyph( `tab.slur.effect.${n.id}`, n, n.effectSlurDestination, false, - false + false, + slurText ); this._effectSlurs.push(effectSlur); this.addTie(effectSlur); diff --git a/packages/alphatab/src/rendering/glyphs/TabSlurGlyph.ts b/packages/alphatab/src/rendering/glyphs/TabSlurGlyph.ts index 97ba66030..88935e421 100644 --- a/packages/alphatab/src/rendering/glyphs/TabSlurGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/TabSlurGlyph.ts @@ -7,16 +7,22 @@ import { BeamDirection } from '@coderline/alphatab/rendering/utils/BeamDirection */ export class TabSlurGlyph extends TabTieGlyph { private _forSlide: boolean; + private readonly _slurText?: string; - public constructor(slurEffectId: string, startNote: Note, endNote: Note, forSlide: boolean, forEnd:boolean) { + public constructor(slurEffectId: string, startNote: Note, endNote: Note, forSlide: boolean, forEnd:boolean, slurText?: string) { super(slurEffectId, startNote, endNote, forEnd); this._forSlide = forSlide; + this._slurText = slurText; } public override getTieHeight(startX: number, _startY: number, endX: number, _endY: number): number { return (Math.log(endX - startX + 1) * this.renderer.settings.notation.slurHeight) / 2; } + protected override getSlurText(): string | undefined { + return this._slurText; + } + public tryExpand(startNote: Note, endNote: Note, forSlide: boolean, forEnd: boolean): boolean { // same type required if (this._forSlide !== forSlide) { diff --git a/packages/alphatab/src/rendering/glyphs/TieGlyph.ts b/packages/alphatab/src/rendering/glyphs/TieGlyph.ts index 5b96249a0..8a0cf8119 100644 --- a/packages/alphatab/src/rendering/glyphs/TieGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/TieGlyph.ts @@ -165,6 +165,8 @@ export abstract class TieGlyph extends Glyph implements ITieGlyph { return; } + const isDown = this.tieDirection === BeamDirection.Down; + if (this.shouldDrawBendSlur()) { TieGlyph.drawBendSlur( canvas, @@ -172,7 +174,7 @@ export abstract class TieGlyph extends Glyph implements ITieGlyph { cy + this._startY, cx + this._endX, cy + this._endY, - this.tieDirection === BeamDirection.Down, + isDown, this.renderer.smuflMetrics.tieHeight ); } else { @@ -183,11 +185,31 @@ export abstract class TieGlyph extends Glyph implements ITieGlyph { cy + this._startY, cx + this._endX, cy + this._endY, - this.tieDirection === BeamDirection.Down, + isDown, this._tieHeight, this.renderer.smuflMetrics.tieMidpointThickness ); } + + const slurText = this.getSlurText(); + if (slurText) { + const midX = cx + (this._startX + this._endX) / 2; + const midY = cy + (this._startY + this._endY) / 2; + const apexOffset = this._tieHeight * 0.75; + const apexY = midY + (isDown ? apexOffset : -apexOffset); + const w = canvas.measureText(slurText).width; + const fontSize = canvas.font.size; + // text above: fontSize already includes descender space below the baseline, + // providing natural padding for capital letters like H/P + const textY = isDown + ? apexY + fontSize * 0.3 + : apexY - fontSize * 1.05; + canvas.fillText(slurText, midX - w / 2, textY); + } + } + + protected getSlurText(): string | undefined { + return undefined; } protected abstract shouldDrawBendSlur(): boolean; From aa535dfa96a4576d8b7db243c8aab1ce8d7751e9 Mon Sep 17 00:00:00 2001 From: Rafael Sales Date: Thu, 2 Apr 2026 11:39:29 -0300 Subject: [PATCH 2/2] fix(tab): handle H/P chain and same-beat edge cases - Individual arcs per H/P pair in chains (e.g. 5{h} 7{h} 5 now renders separate H and P arcs instead of one collapsed arc) - Prevent tryExpand from merging slurs with different H/P labels (fixes label loss when multiple H/P on same beat share beam direction) - Guard existing effectSlur blocks to skip H/P notes, keeping legato slide rendering unaffected --- .../rendering/glyphs/TabBeatContainerGlyph.ts | 63 ++++++++++++++++--- .../src/rendering/glyphs/TabSlurGlyph.ts | 6 +- 2 files changed, 58 insertions(+), 11 deletions(-) diff --git a/packages/alphatab/src/rendering/glyphs/TabBeatContainerGlyph.ts b/packages/alphatab/src/rendering/glyphs/TabBeatContainerGlyph.ts index 2c5d9fd41..adba41f6e 100644 --- a/packages/alphatab/src/rendering/glyphs/TabBeatContainerGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/TabBeatContainerGlyph.ts @@ -56,24 +56,22 @@ export class TabBeatContainerGlyph extends BeatContainerGlyph { const tapSlur: TabTieGlyph = new TabTieGlyph(`tab.tie.leftHandTap.${n.id}`, n, n, false); this.addTie(tapSlur); } - // start effect slur on first beat - if (n.isEffectSlurOrigin && n.effectSlurDestination) { + // H/P arc start-side: create individual arc per hammer-pull pair + if (n.isHammerPullOrigin && n.hammerPullDestination) { + const dest = n.hammerPullDestination; + const slurText = dest.fret >= n.fret ? 'H' : 'P'; let expanded: boolean = false; for (const slur of this._effectSlurs) { - if (slur.tryExpand(n, n.effectSlurDestination, false, false)) { + if (slur.tryExpand(n, dest, false, false, slurText)) { expanded = true; break; } } if (!expanded) { - let slurText: string | undefined = undefined; - if (n.isHammerPullOrigin && n.hammerPullDestination) { - slurText = n.hammerPullDestination.fret >= n.fret ? 'H' : 'P'; - } const effectSlur: TabSlurGlyph = new TabSlurGlyph( `tab.slur.effect.${n.id}`, n, - n.effectSlurDestination, + dest, false, false, slurText @@ -82,8 +80,53 @@ export class TabBeatContainerGlyph extends BeatContainerGlyph { this.addTie(effectSlur); } } - // end effect slur on last beat - if (n.isEffectSlurDestination && n.effectSlurOrigin) { + // H/P arc end-side: for cross-bar rendering + if (n.isHammerPullDestination && n.hammerPullOrigin) { + const origin = n.hammerPullOrigin; + const slurText = n.fret >= origin.fret ? 'H' : 'P'; + let expanded: boolean = false; + for (const slur of this._effectSlurs) { + if (slur.tryExpand(origin, n, false, true, slurText)) { + expanded = true; + break; + } + } + if (!expanded) { + const effectSlur: TabSlurGlyph = new TabSlurGlyph( + `tab.slur.effect.${origin.id}`, + origin, + n, + false, + true, + slurText + ); + this._effectSlurs.push(effectSlur); + this.addTie(effectSlur); + } + } + // start non-H/P effect slur (e.g. legato slide) + if (n.isEffectSlurOrigin && n.effectSlurDestination && !n.isHammerPullOrigin) { + let expanded: boolean = false; + for (const slur of this._effectSlurs) { + if (slur.tryExpand(n, n.effectSlurDestination, false, false)) { + expanded = true; + break; + } + } + if (!expanded) { + const effectSlur: TabSlurGlyph = new TabSlurGlyph( + `tab.slur.effect.${n.id}`, + n, + n.effectSlurDestination, + false, + false + ); + this._effectSlurs.push(effectSlur); + this.addTie(effectSlur); + } + } + // end non-H/P effect slur + if (n.isEffectSlurDestination && n.effectSlurOrigin && !n.isHammerPullDestination) { let expanded: boolean = false; for (const slur of this._effectSlurs) { if (slur.tryExpand(n.effectSlurOrigin, n, false, true)) { diff --git a/packages/alphatab/src/rendering/glyphs/TabSlurGlyph.ts b/packages/alphatab/src/rendering/glyphs/TabSlurGlyph.ts index 88935e421..4c34d45c7 100644 --- a/packages/alphatab/src/rendering/glyphs/TabSlurGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/TabSlurGlyph.ts @@ -23,7 +23,11 @@ export class TabSlurGlyph extends TabTieGlyph { return this._slurText; } - public tryExpand(startNote: Note, endNote: Note, forSlide: boolean, forEnd: boolean): boolean { + public tryExpand(startNote: Note, endNote: Note, forSlide: boolean, forEnd: boolean, slurText?: string): boolean { + // same label required (when provided) + if (slurText !== undefined && this._slurText !== slurText) { + return false; + } // same type required if (this._forSlide !== forSlide) { return false;