Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion packages/alphatab/src/NotationSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,17 @@ export enum NotationElement {
/**
* The slurs shown on bend effects within the score staff.
*/
ScoreBendSlur = 55
ScoreBendSlur = 55,

/**
* The hammer-on pull-off text shown on slurs.
*/
EffectHammerOnPullOffText = 56,

/**
* The slide text shown on slurs.
*/
EffectSlideText = 57
}

/**
Expand Down
14 changes: 12 additions & 2 deletions packages/alphatab/src/RenderingResources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ export class RenderingResources {
[NotationElement.RepeatCount, new Font(RenderingResources._sansFont, 11, FontStyle.Plain)],
[NotationElement.BarNumber, new Font(RenderingResources._sansFont, 11, FontStyle.Plain)],
[NotationElement.ScoreBendSlur, new Font(RenderingResources._sansFont, 11, FontStyle.Plain)],
[NotationElement.EffectAlternateEndings, new Font(RenderingResources._serifFont, 15, FontStyle.Plain)]
[NotationElement.EffectAlternateEndings, new Font(RenderingResources._serifFont, 15, FontStyle.Plain)],
[NotationElement.EffectHammerOnPullOffText, RenderingResources._effectFont],
[NotationElement.EffectSlideText, RenderingResources._effectFont]
]);

/**
Expand Down Expand Up @@ -381,8 +383,16 @@ export class RenderingResources {
break;
}

return this.getFontForNotationElement(notationElement);
}

/**
* @internal
* @param element
*/
public getFontForNotationElement(notationElement: NotationElement): Font {
return this.elementFonts.has(notationElement)
? this.elementFonts.get(notationElement)!
: RenderingResources.defaultFonts.get(NotationElement.ScoreWords)!;
: RenderingResources.defaultFonts.get(notationElement)!;
}
}
19 changes: 19 additions & 0 deletions packages/alphatab/src/model/Beat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { GraceType } from '@coderline/alphatab/model/GraceType';
import { Note } from '@coderline/alphatab/model/Note';
import { Ottavia } from '@coderline/alphatab/model/Ottavia';
import { PickStroke } from '@coderline/alphatab/model/PickStroke';
import type { Slur } from '@coderline/alphatab/model/Slur';
import { TupletGroup } from '@coderline/alphatab/model/TupletGroup';
import { VibratoType } from '@coderline/alphatab/model/VibratoType';
import type { Voice } from '@coderline/alphatab/model/Voice';
Expand Down Expand Up @@ -687,6 +688,24 @@ export class Beat {
*/
public effectSlurDestination: Beat | null = null;

/**
* Convenience accessor for the {@link Slur} of this beat. Returns
* the effect slur of whichever note in this beat owns it (the
* chain-origin note populated during `Note.finish()`), or `null`
* when no note in the beat is an effect-slur origin.
* @clone_ignore
* @json_ignore
* @internal
*/
public get effectSlur(): Slur | null {
for (const n of this.notes) {
if (n.effectSlur !== null) {
return n.effectSlur;
}
}
return null;
}

/**
* Gets or sets how the beaming should be done for this beat.
*/
Expand Down
48 changes: 45 additions & 3 deletions packages/alphatab/src/model/Note.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { NoteAccidentalMode } from '@coderline/alphatab/model/NoteAccidentalMode
import { Ottavia } from '@coderline/alphatab/model/Ottavia';
import { SlideInType } from '@coderline/alphatab/model/SlideInType';
import { SlideOutType } from '@coderline/alphatab/model/SlideOutType';
import { Slur } from '@coderline/alphatab/model/Slur';
import { SlurSegmentKind } from '@coderline/alphatab/model/SlurSegmentKind';
import type { Staff } from '@coderline/alphatab/model/Staff';
import { VibratoType } from '@coderline/alphatab/model/VibratoType';
import { NotationMode } from '@coderline/alphatab/NotationSettings';
Expand Down Expand Up @@ -604,6 +606,17 @@ export class Note {
*/
public effectSlurDestination: Note | null = null;

/**
* The {@link Slur} object whose origin is this note. Populated by
* `finish()`; non-null only on the chain-origin note of an effect
* slur. Carries the inner articulation segments used by the
* renderer to paint H/P/sl. labels along the arc.
* @clone_ignore
* @json_ignore
* @internal
*/
public effectSlur: Slur | null = null;

/**
* The ornament applied on the note.
*/
Expand Down Expand Up @@ -906,21 +919,50 @@ export class Note {
break;
}
let effectSlurDestination: Note | null = null;
let effectSlurSegmentKind: SlurSegmentKind | null = null;
if (this.isHammerPullOrigin && this.hammerPullDestination) {
effectSlurDestination = this.hammerPullDestination;
effectSlurSegmentKind = SlurSegmentKind.HammerPull;
} else if (this.slideOutType === SlideOutType.Legato && this.slideTarget) {
effectSlurDestination = this.slideTarget;
effectSlurSegmentKind = SlurSegmentKind.LegatoSlide;
}
if (effectSlurDestination) {
this.hasEffectSlur = true;
if (this.effectSlurOrigin && this.beat.pickStroke === PickStroke.None) {
this.effectSlurOrigin.effectSlurDestination = effectSlurDestination;
this.effectSlurOrigin.effectSlurDestination.effectSlurOrigin = this.effectSlurOrigin;
const chainOrigin = this.effectSlurOrigin;
chainOrigin.effectSlurDestination = effectSlurDestination;
effectSlurDestination.effectSlurOrigin = chainOrigin;
this.effectSlurOrigin = null;

if (effectSlurSegmentKind !== null && chainOrigin.effectSlur !== null) {
chainOrigin.effectSlur.destinationNote = effectSlurDestination;
chainOrigin.effectSlur.segments.push({
fromNote: this,
toNote: effectSlurDestination,
kind: effectSlurSegmentKind,
text: null
});
}
} else {
this.isEffectSlurOrigin = true;
this.effectSlurDestination = effectSlurDestination;
this.effectSlurDestination.effectSlurOrigin = this;
effectSlurDestination.effectSlurOrigin = this;

// Always allocate a fresh Slur — finish() may run twice (worker re-finish);
// overwriting unconditionally keeps the derivation idempotent.
const slur = new Slur();
slur.originNote = this;
slur.destinationNote = effectSlurDestination;
if (effectSlurSegmentKind !== null) {
slur.segments.push({
fromNote: this,
toNote: effectSlurDestination,
kind: effectSlurSegmentKind,
text: null
});
}
this.effectSlur = slur;
}
}
// try to detect what kind of bend was used and cleans unneeded points if required
Expand Down
19 changes: 19 additions & 0 deletions packages/alphatab/src/model/Slur.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { Note } from '@coderline/alphatab/model/Note';
import type { SlurSegment } from '@coderline/alphatab/model/SlurSegment';

/**
* A slur arc spanning two notes, optionally with inner articulation
* segments. Corresponds conceptually to a MusicXML `<slur>` element
* plus the technique spans inside it.
*
* For this PR only effect slurs (hammer-pull + legato-slide chains)
* are derived in `Note.finish()`. Phrase and legato slurs may join
* this type in a future PR; a discriminator will be added at that
* point.
* @internal
*/
export class Slur {
public originNote!: Note;
public destinationNote!: Note;
public segments: SlurSegment[] = [];
}
22 changes: 22 additions & 0 deletions packages/alphatab/src/model/SlurSegment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { Note } from '@coderline/alphatab/model/Note';
import type { SlurSegmentKind } from '@coderline/alphatab/model/SlurSegmentKind';

/**
* One inner articulation span inside a {@link Slur}. Corresponds
* conceptually to a MusicXML `<hammer-on>` / `<pull-off>` / `<slide>`
* start-stop pair nested inside the surrounding `<slur>` element.
* @record
* @internal
*/
export interface SlurSegment {
fromNote: Note;
toNote: Note;
kind: SlurSegmentKind;
/**
* Optional explicit label preserved from an external source (e.g. a
* future importer that reads MusicXML element text content).
* When null, the renderer derives the label from `kind` and note
* context — H vs P by fret/realValue comparison, "sl." for slides.
*/
text: string | null;
}
13 changes: 13 additions & 0 deletions packages/alphatab/src/model/SlurSegmentKind.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Articulation kind for an inner span of a {@link Slur}.
*
* Drives the renderer's font selection (which {@link NotationElement} to
* use) and the default label text when {@link SlurSegment.text} is null.
* `Note.finish()` classifies the kind once when building the slur; the
* renderer never re-derives it.
* @internal
*/
export enum SlurSegmentKind {
HammerPull = 0,
LegatoSlide = 1
}
20 changes: 20 additions & 0 deletions packages/alphatab/src/rendering/glyphs/ScoreSlurGlyph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,36 @@ import { GraceType } from '@coderline/alphatab/model/GraceType';
import { NoteXPosition, NoteYPosition } from '@coderline/alphatab/rendering/BarRendererBase';
import { BeatXPosition } from '@coderline/alphatab/rendering/BeatXPosition';
import { ScoreTieGlyph } from '@coderline/alphatab/rendering/glyphs/ScoreTieGlyph';
import { TieGlyphLabels, type TieGlyphLabel } from '@coderline/alphatab/rendering/glyphs/TieGlyphLabel';
import { BeamDirection } from '@coderline/alphatab/rendering/utils/BeamDirection';

/**
* @internal
*/
export class ScoreSlurGlyph extends ScoreTieGlyph {
private _labels: TieGlyphLabel[] | null = null;

public override getTieHeight(startX: number, _startY: number, endX: number, _endY: number): number {
return (Math.log2(endX - startX + 1) * this.renderer.settings.notation.slurHeight) / 2;
}

protected override getSlurLabels(): TieGlyphLabel[] | null {
if (this._labels === null) {
this._labels = [];
const slur = this.startNote.beat.effectSlur;
if (slur !== null) {
const notationSettings = this.renderer.settings.notation;
for (const s of slur.segments) {
const label = TieGlyphLabels.build(s, s.toNote.realValue >= s.fromNote.realValue);
if (notationSettings.isNotationElementVisible(label.element)) {
this._labels.push(label);
}
}
}
}
return this._labels.length > 0 ? this._labels : null;
}

protected override calculateStartX(): number {
return (
this.renderer.x +
Expand Down
23 changes: 22 additions & 1 deletion packages/alphatab/src/rendering/glyphs/TabSlurGlyph.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import type { Note } from '@coderline/alphatab/model/Note';
import { TabTieGlyph } from '@coderline/alphatab/rendering/glyphs/TabTieGlyph';
import { TieGlyphLabels, type TieGlyphLabel } from '@coderline/alphatab/rendering/glyphs/TieGlyphLabel';
import { BeamDirection } from '@coderline/alphatab/rendering/utils/BeamDirection';

/**
* @internal
*/
export class TabSlurGlyph extends TabTieGlyph {
private _forSlide: boolean;
private _labels: TieGlyphLabel[] | null = null;

public constructor(slurEffectId: string, startNote: Note, endNote: Note, forSlide: boolean, forEnd:boolean) {
public constructor(slurEffectId: string, startNote: Note, endNote: Note, forSlide: boolean, forEnd: boolean) {
super(slurEffectId, startNote, endNote, forEnd);
this._forSlide = forSlide;
}
Expand All @@ -17,6 +19,23 @@ export class TabSlurGlyph extends TabTieGlyph {
return (Math.log(endX - startX + 1) * this.renderer.settings.notation.slurHeight) / 2;
}

protected override getSlurLabels(): TieGlyphLabel[] | null {
if (this._labels === null) {
this._labels = [];
const slur = this.startNote.effectSlur;
if (slur !== null) {
const notationSettings = this.renderer.settings.notation;
for (const s of slur.segments) {
const label = TieGlyphLabels.build(s, s.toNote.fret >= s.fromNote.fret);
if (notationSettings.isNotationElementVisible(label.element)) {
this._labels.push(label);
}
}
}
}
return this._labels.length > 0 ? this._labels : null;
}

public tryExpand(startNote: Note, endNote: Note, forSlide: boolean, forEnd: boolean): boolean {
// same type required
if (this._forSlide !== forSlide) {
Expand All @@ -42,6 +61,7 @@ export class TabSlurGlyph extends TabTieGlyph {
case BeamDirection.Up:
if (startNote.realValue > this.startNote.realValue) {
this.startNote = startNote;
this._labels = null; // invalidate cache — labels live on startNote
}
if (endNote.realValue > this.endNote.realValue) {
this.endNote = endNote;
Expand All @@ -50,6 +70,7 @@ export class TabSlurGlyph extends TabTieGlyph {
case BeamDirection.Down:
if (startNote.realValue < this.startNote.realValue) {
this.startNote = startNote;
this._labels = null;
}
if (endNote.realValue < this.endNote.realValue) {
this.endNote = endNote;
Expand Down
Loading
Loading