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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
11 changes: 10 additions & 1 deletion packages/alphatab/src/Environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import { createSlightNoteVibratoEffectInfo } from '@coderline/alphatab/rendering
import { sustainPedalEffectInfo } from '@coderline/alphatab/rendering/effects/SustainPedalEffectInfo';
import { tabWhammyEffectInfo } from '@coderline/alphatab/rendering/effects/TabWhammyEffectInfo';
import { tapEffectInfo } from '@coderline/alphatab/rendering/effects/TapEffectInfo';
import { hammerPullLabelEffectInfo, slideLabelEffectInfo } from '@coderline/alphatab/rendering/effects/EffectSlurLabelEffectInfo';
import { tempoEffectInfo } from '@coderline/alphatab/rendering/effects/TempoEffectInfo';
import { textEffectInfo } from '@coderline/alphatab/rendering/effects/TextEffectInfo';
import { trillEffectInfo } from '@coderline/alphatab/rendering/effects/TrillEffectInfo';
Expand Down Expand Up @@ -512,6 +513,9 @@ export class Environment {
},

{ effect: createGolpeEffectInfo(GolpeType.Finger), mode: EffectBandMode.OwnedTop },
// last = closest to the staff: H/P directly above the staff, sl. stacked above
{ effect: slideLabelEffectInfo, mode: EffectBandMode.OwnedTop },
{ effect: hammerPullLabelEffectInfo, mode: EffectBandMode.OwnedTop },

{ effect: createGolpeEffectInfo(GolpeType.Thumb), mode: EffectBandMode.OwnedBottom },
{ effect: crescendoEffectInfo, mode: EffectBandMode.SharedBottom },
Expand All @@ -524,7 +528,9 @@ export class Environment {
//
// Numbered
new NumberedBarRendererFactory([
{ effect: numberedBarKeySignatureEffectInfo, mode: EffectBandMode.OwnedTop, order: 1000 }
{ effect: numberedBarKeySignatureEffectInfo, mode: EffectBandMode.OwnedTop, order: 1000 },
{ effect: slideLabelEffectInfo, mode: EffectBandMode.OwnedTop },
{ effect: hammerPullLabelEffectInfo, mode: EffectBandMode.OwnedTop }
]),

//
Expand Down Expand Up @@ -557,6 +563,9 @@ export class Environment {
mode: EffectBandMode.OwnedTop,
shouldCreate: staff => !staff.showStandardNotation
},
// last = closest to the staff: H/P directly above the staff, sl. stacked above
{ effect: slideLabelEffectInfo, mode: EffectBandMode.OwnedTop },
{ effect: hammerPullLabelEffectInfo, mode: EffectBandMode.OwnedTop },

{
effect: createGolpeEffectInfo(GolpeType.Thumb),
Expand Down
23 changes: 19 additions & 4 deletions packages/alphatab/src/model/Note.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ 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 type { SlurSegment } from '@coderline/alphatab/model/SlurSegment';
import { SlurSegmentKind } from '@coderline/alphatab/model/SlurSegmentKind';
import type { Staff } from '@coderline/alphatab/model/Staff';
import { VibratoType } from '@coderline/alphatab/model/VibratoType';
Expand Down Expand Up @@ -620,6 +621,15 @@ export class Note {
*/
public effectSlur: Slur | null = null;

/**
* The {@link SlurSegment} of the effect slur chain starting at this note
* (hammer-on, pull-off or legato slide to the next note). Populated by `finish()`.
* @clone_ignore
* @json_ignore
* @internal
*/
public effectSlurSegment: SlurSegment | null = null;

/**
* The ornament applied on the note.
*/
Expand Down Expand Up @@ -936,6 +946,7 @@ export class Note {
}
break;
}
this.effectSlurSegment = null;
let effectSlurDestination: Note | null = null;
let effectSlurSegmentKind: SlurSegmentKind | null = null;
if (this.isHammerPullOrigin && this.hammerPullDestination) {
Expand All @@ -955,12 +966,14 @@ export class Note {

if (effectSlurSegmentKind !== null && chainOrigin.effectSlur !== null) {
chainOrigin.effectSlur.destinationNote = effectSlurDestination;
chainOrigin.effectSlur.segments.push({
const segment: SlurSegment = {
fromNote: this,
toNote: effectSlurDestination,
kind: effectSlurSegmentKind,
text: null
});
};
chainOrigin.effectSlur.segments.push(segment);
this.effectSlurSegment = segment;
}
} else {
this.isEffectSlurOrigin = true;
Expand All @@ -973,12 +986,14 @@ export class Note {
slur.originNote = this;
slur.destinationNote = effectSlurDestination;
if (effectSlurSegmentKind !== null) {
slur.segments.push({
const segment: SlurSegment = {
fromNote: this,
toNote: effectSlurDestination,
kind: effectSlurSegmentKind,
text: null
});
};
slur.segments.push(segment);
this.effectSlurSegment = segment;
}
this.effectSlur = slur;
}
Expand Down
10 changes: 10 additions & 0 deletions packages/alphatab/src/rendering/BarRendererBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,16 @@ export class BarRendererBase {
return this._contentTopOverflow + this.topEffects.height;
}

/** The overflow of the bar content above the staff, without the effect bands. */
public get contentTopOverflow() {
return this._contentTopOverflow;
}

/** The overflow of the bar content below the staff, without the effect bands. */
public get contentBottomOverflow() {
return this._contentBottomOverflow;
}

public get bottomOverflow() {
return this._contentBottomOverflow + this.bottomEffects.height;
}
Expand Down
80 changes: 79 additions & 1 deletion packages/alphatab/src/rendering/EffectBand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import { type Beat, BeatSubElement } from '@coderline/alphatab/model/Beat';
import type { Voice } from '@coderline/alphatab/model/Voice';
import type { ICanvas } from '@coderline/alphatab/platform/ICanvas';
import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase';
import { BeatXPosition } from '@coderline/alphatab/rendering/BeatXPosition';
import type { EffectBandContainer } from '@coderline/alphatab/rendering/EffectBandContainer';
import { EffectBarGlyphSizing } from '@coderline/alphatab/rendering/EffectBarGlyphSizing';
import type { EffectInfo } from '@coderline/alphatab/rendering/EffectInfo';
import { EffectBandPlacementCategory, type EffectInfo } from '@coderline/alphatab/rendering/EffectInfo';
import type { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph';
import { Glyph } from '@coderline/alphatab/rendering/glyphs/Glyph';
import { GroupedEffectGlyph } from '@coderline/alphatab/rendering/glyphs/GroupedEffectGlyph';
Expand Down Expand Up @@ -118,6 +119,14 @@ export class EffectBand extends Glyph {
}
}

/**
* Marks the x-range as stale after glyph extents changed outside {@link alignGlyphs}
* (e.g. resolved in {@link EffectInfo.finalizeBand}).
*/
public invalidateXRange(): void {
this._xRangeBaseDirty = true;
}

public clearPublishedSpans(): void {
// Defer base recomputation if stale (alignGlyphs invalidated it).
if (this._xRangeBaseDirty) {
Expand Down Expand Up @@ -423,6 +432,75 @@ export class EffectBand extends Glyph {
return true;
}

private readonly _placementRangeScratch: EffectBandXRange = { xStart: 0, xEnd: 0 };

/**
* Collects the renderer-local x-ranges of this band for the vertical placement: the ranges which must be
* clear of other content (`clearStarts`/`clearEnds`) and the ranges the band occupies (`xStarts`/`xEnds`).
* @remarks
* Note-attached bands (markers like tap or the hammer-on/pull-off labels) place each glyph on its own
* ({@link EffectGlyph.getPlacementClearanceLeft}), so other markers can share the row in the gaps
* between them. Markers attached to a beat keep the noteheads and stems of their beat clear, even where
* the marker is narrower (e.g. a pick stroke above the stem of its note). All other bands (lines, spans,
* system markers) and bands with cross-bar spans keep and occupy their whole range
* ({@link computeLocalXRange}).
* @returns `false` when the band has no usable range.
*/
public collectPlacementRanges(
clearStarts: number[],
clearEnds: number[],
xStarts: number[],
xEnds: number[]
): boolean {
if (this.isEmpty) {
return false;
}
if (
this.info.placementCategory === EffectBandPlacementCategory.NoteAttached &&
this.info.sizingMode !== EffectBarGlyphSizing.FullBar &&
this._chainHeads.length === 0
) {
const sizing = this.info.sizingMode;
const beatAttached =
sizing === EffectBarGlyphSizing.SingleOnBeat ||
sizing === EffectBarGlyphSizing.SingleOnBeatToEnd ||
sizing === EffectBarGlyphSizing.GroupedOnBeat ||
sizing === EffectBarGlyphSizing.GroupedOnBeatToEnd;
let found = false;
for (const v of this._uniqueEffectGlyphs) {
for (const g of v) {
const left = g.getBoundingBoxLeft();
const right = g.getBoundingBoxRight();
if (Number.isNaN(left) || Number.isNaN(right)) {
continue;
}
let clearStart = Math.min(left, g.getPlacementClearanceLeft());
let clearEnd = Math.max(right, g.getPlacementClearanceRight());
if (beatAttached && g.beat) {
clearStart = Math.min(clearStart, this.renderer.getBeatX(g.beat, BeatXPosition.OnNotes));
clearEnd = Math.max(clearEnd, this.renderer.getBeatX(g.beat, BeatXPosition.PostNotes));
}
clearStarts.push(clearStart);
clearEnds.push(clearEnd);
xStarts.push(left);
xEnds.push(right);
found = true;
}
}
return found;
}

const range = this._placementRangeScratch;
if (!this.computeLocalXRange(range)) {
return false;
}
clearStarts.push(range.xStart);
clearEnds.push(range.xEnd);
xStarts.push(range.xStart);
xEnds.push(range.xEnd);
return true;
}

private _alignGlyph(sizing: EffectBarGlyphSizing, beat: Beat): void {
const g: EffectGlyph = this._effectGlyphs[beat.voice.index].get(beat.index)!;
const container = this.renderer.getBeatContainer(beat)!;
Expand Down
78 changes: 47 additions & 31 deletions packages/alphatab/src/rendering/EffectSystemPlacement.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { EffectBand, EffectBandXRange } from '@coderline/alphatab/rendering/EffectBand';
import type { EffectBand } from '@coderline/alphatab/rendering/EffectBand';
import { EffectBandPlacementCategory } from '@coderline/alphatab/rendering/EffectInfo';
import type { Skyline } from '@coderline/alphatab/rendering/skyline/Skyline';
import type { RenderStaff } from '@coderline/alphatab/rendering/staves/RenderStaff';
Expand All @@ -14,12 +14,11 @@ export class EffectSystemPlacement {
// Reusable scratch buffers; rebuilt every finalize cycle.
private readonly _top: EffectBand[] = [];
private readonly _bottom: EffectBand[] = [];
private readonly _contentTop: number[] = [];
private readonly _contentBottom: number[] = [];
private readonly _groupBands: EffectBand[] = [];
private readonly _groupXStarts: number[] = [];
private readonly _groupXEnds: number[] = [];
private readonly _xRangeScratch: EffectBandXRange = { xStart: 0, xEnd: 0 };
private readonly _clearXStarts: number[] = [];
private readonly _clearXEnds: number[] = [];

public constructor(staff: RenderStaff) {
this._staff = staff;
Expand All @@ -32,22 +31,14 @@ export class EffectSystemPlacement {

const top = this._top;
const bottom = this._bottom;
const contentTop = this._contentTop;
const contentBottom = this._contentBottom;
// splice() instead of `.length = 0`: transpile-safe array clear.
top.splice(0, top.length);
bottom.splice(0, bottom.length);
contentTop.splice(0, contentTop.length);
contentBottom.splice(0, contentBottom.length);

// container.height = post-placement max - pre-placement max.
// Snapshot pre-placement skyline, filter non-empty bands, and run
// `finalizeBand` (settles dynamic-height effects like TabWhammy) in
// one walk.
// Filter non-empty bands and run `finalizeBand` (settles dynamic-height
// effects like TabWhammy) in one walk.
for (let i = 0; i < staff.barRenderers.length; i++) {
const r = staff.barRenderers[i];
contentTop.push(sky.upSky.maxHeightInRange(r.x, r.x + r.width));
contentBottom.push(sky.downSky.maxHeightInRange(r.x, r.x + r.width));
for (const b of r.topEffects.bands) {
if (!b.isEmpty) {
// Reset; `_placeSide` only writes it when computeLocalXRange succeeds,
Expand Down Expand Up @@ -76,13 +67,17 @@ export class EffectSystemPlacement {
this._placeSide(top, sky.upSky, contentSky ? contentSky.upSky : null, pad, /* isTop */ true);
this._placeSide(bottom, sky.downSky, contentSky ? contentSky.downSky : null, pad, /* isTop */ false);

// A bar reserves its own content and the bands placed on it. Bands know their final
// position (magnitude + height), so the reserved height does not depend on the skyline before
// placement, which also contains content of other bars reaching into this bar (ties, brackets)
// and misses content registered as overflow without a skyline entry.
for (let i = 0; i < staff.barRenderers.length; i++) {
const r = staff.barRenderers[i];
const topMax = sky.upSky.maxHeightInRange(r.x, r.x + r.width);
r.topEffects.height = Math.max(0, Math.ceil(topMax - contentTop[i]));

const bottomMax = sky.downSky.maxHeightInRange(r.x, r.x + r.width);
r.bottomEffects.height = Math.max(0, Math.ceil(bottomMax - contentBottom[i]));
r.topEffects.height = EffectSystemPlacement._effectsHeight(r.topEffects.bands, r.contentTopOverflow);
r.bottomEffects.height = EffectSystemPlacement._effectsHeight(
r.bottomEffects.bands,
r.contentBottomOverflow
);

r.registerStaffOverflows();
}
Expand All @@ -97,6 +92,20 @@ export class EffectSystemPlacement {
}
}

/** The height the placed bands add on top of the given content overflow. */
private static _effectsHeight(bands: EffectBand[], contentOverflow: number): number {
let max = 0;
for (const b of bands) {
if (!b.isEmpty) {
const outer = b.placedMagnitude + b.height;
if (outer > max) {
max = outer;
}
}
}
return Math.max(0, Math.ceil(max - contentOverflow));
}

/** Sort by precomputed {@link EffectBand.sortKey} (placementCategory, order desc, voice, renderer). */
private static _sortByPriority(bands: EffectBand[]): void {
bands.sort((a, b) => a.sortKey - b.sortKey);
Expand Down Expand Up @@ -150,24 +159,31 @@ export class EffectSystemPlacement {
groupBands.splice(0, groupBands.length);
groupXStarts.splice(0, groupXStarts.length);
groupXEnds.splice(0, groupXEnds.length);
const xRange = this._xRangeScratch;
let groupMagnitude = 0;
for (let k = i; k < groupEnd; k++) {
const m = bands[k];
if (!m.computeLocalXRange(xRange)) {
// one entry per occupied range (note-attached bands: one per glyph)
const clearStarts = this._clearXStarts;
const clearEnds = this._clearXEnds;
clearStarts.splice(0, clearStarts.length);
clearEnds.splice(0, clearEnds.length);
const firstRange = groupXStarts.length;
if (!m.collectPlacementRanges(clearStarts, clearEnds, groupXStarts, groupXEnds)) {
continue;
}
const xStart = m.renderer.x + xRange.xStart;
const xEnd = m.renderer.x + xRange.xEnd;
const mag = isTop
? querySky.placeAbove(xStart, xEnd, m.height, pad)
: querySky.placeBelow(xStart, xEnd, m.height, pad);
if (mag > groupMagnitude) {
groupMagnitude = mag;
for (let r = firstRange; r < groupXStarts.length; r++) {
groupXStarts[r] = m.renderer.x + groupXStarts[r];
groupXEnds[r] = m.renderer.x + groupXEnds[r];
const clearStart = m.renderer.x + clearStarts[r - firstRange];
const clearEnd = m.renderer.x + clearEnds[r - firstRange];
const mag = isTop
? querySky.placeAbove(clearStart, clearEnd, m.height, pad)
: querySky.placeBelow(clearStart, clearEnd, m.height, pad);
if (mag > groupMagnitude) {
groupMagnitude = mag;
}
groupBands.push(m);
}
groupBands.push(m);
groupXStarts.push(xStart);
groupXEnds.push(xEnd);
}
for (let k = 0; k < groupBands.length; k++) {
const b = groupBands[k];
Expand Down
Loading
Loading