diff --git a/packages/alphatab/src/DisplaySettings.ts b/packages/alphatab/src/DisplaySettings.ts index 4fce2772d..324217afa 100644 --- a/packages/alphatab/src/DisplaySettings.ts +++ b/packages/alphatab/src/DisplaySettings.ts @@ -319,12 +319,16 @@ export class DisplaySettings { public staffPaddingLeft: number = 2; /** - * The padding between individual effect bands. + * Clearance applied around each effect band on its staff-facing side: + * "bottom padding" for top bands (between the band and the staff or + * the band stacked below it) and "top padding" for bottom bands + * (mirrored). Also used as the inter-band gap when bands stack on + * the same side. * @since 1.7.0 * @category Display - * @defaultValue `2` + * @defaultValue `5` */ - public effectBandPaddingBottom = 2; + public effectBandPaddingBottom = 5; /** * The additional padding to apply between the staves of two separate tracks. diff --git a/packages/alphatab/src/generated/DisplaySettingsJson.ts b/packages/alphatab/src/generated/DisplaySettingsJson.ts index 20cb54ddb..f8c8346a7 100644 --- a/packages/alphatab/src/generated/DisplaySettingsJson.ts +++ b/packages/alphatab/src/generated/DisplaySettingsJson.ts @@ -279,10 +279,14 @@ export interface DisplaySettingsJson { */ staffPaddingLeft?: number; /** - * The padding between individual effect bands. + * Clearance applied around each effect band on its staff-facing side: + * "bottom padding" for top bands (between the band and the staff or + * the band stacked below it) and "top padding" for bottom bands + * (mirrored). Also used as the inter-band gap when bands stack on + * the same side. * @since 1.7.0 * @category Display - * @defaultValue `2` + * @defaultValue `5` */ effectBandPaddingBottom?: number; /** diff --git a/packages/alphatab/src/rendering/BarRendererBase.ts b/packages/alphatab/src/rendering/BarRendererBase.ts index dd015bec6..3dcb7987d 100644 --- a/packages/alphatab/src/rendering/BarRendererBase.ts +++ b/packages/alphatab/src/rendering/BarRendererBase.ts @@ -5,11 +5,13 @@ import type { Note } from '@coderline/alphatab/model/Note'; import { SimileMark } from '@coderline/alphatab/model/SimileMark'; import { type Voice, VoiceSubElement } from '@coderline/alphatab/model/Voice'; import { CanvasHelper, type ICanvas } from '@coderline/alphatab/platform/ICanvas'; +import type { RenderingResources } from '@coderline/alphatab/RenderingResources'; import { BeatXPosition } from '@coderline/alphatab/rendering/BeatXPosition'; import { EffectBandContainer } from '@coderline/alphatab/rendering/EffectBandContainer'; import { BeatContainerGlyph, - type BeatContainerGlyphBase + type BeatContainerGlyphBase, + type BeatEffectOverflow } from '@coderline/alphatab/rendering/glyphs/BeatContainerGlyph'; import type { Glyph } from '@coderline/alphatab/rendering/glyphs/Glyph'; import { LeftToRightLayoutingGlyphGroup } from '@coderline/alphatab/rendering/glyphs/LeftToRightLayoutingGlyphGroup'; @@ -17,6 +19,7 @@ import { MultiVoiceContainerGlyph } from '@coderline/alphatab/rendering/glyphs/M import { ContinuationTieGlyph, type ITieGlyph, type TieGlyph } from '@coderline/alphatab/rendering/glyphs/TieGlyph'; import { MultiBarRestBeatContainerGlyph } from '@coderline/alphatab/rendering/MultiBarRestBeatContainerGlyph'; import type { ScoreRenderer } from '@coderline/alphatab/rendering/ScoreRenderer'; +import { BarLocalSkyline, StaffSide } from '@coderline/alphatab/rendering/skyline/BarLocalSkyline'; import type { BarLayoutingInfo } from '@coderline/alphatab/rendering/staves/BarLayoutingInfo'; import type { RenderStaff } from '@coderline/alphatab/rendering/staves/RenderStaff'; import { BarBounds } from '@coderline/alphatab/rendering/utils/BarBounds'; @@ -25,7 +28,6 @@ import type { BeamingHelper } from '@coderline/alphatab/rendering/utils/BeamingH import { Bounds } from '@coderline/alphatab/rendering/utils/Bounds'; import { ElementStyleHelper } from '@coderline/alphatab/rendering/utils/ElementStyleHelper'; import type { MasterBarBounds } from '@coderline/alphatab/rendering/utils/MasterBarBounds'; -import type { RenderingResources } from '@coderline/alphatab/RenderingResources'; import type { Settings } from '@coderline/alphatab/Settings'; /** @@ -95,6 +97,31 @@ export class BarRendererBase { private _multiSystemSlurs?: ContinuationTieGlyph[]; + /** Set by {@link RenderStaff._finalizeRendererTies} when a tie write grew this renderer's overflow. */ + private _tiesDirty: boolean = false; + + /** Ties whose start beat lives on this renderer. */ + public get ties(): ITieGlyph[] { + return this._ties; + } + + public markTiesDirty(): void { + this._tiesDirty = true; + } + + public get tiesDirty(): boolean { + return this._tiesDirty; + } + + public clearTiesDirty(): void { + this._tiesDirty = false; + } + + /** Multi-system slur continuations attached to this renderer. Only populated on renderer 0 of a staff. */ + public get multiSystemSlurs(): ContinuationTieGlyph[] | undefined { + return this._multiSystemSlurs; + } + public topEffects: EffectBandContainer; public bottomEffects: EffectBandContainer; @@ -126,7 +153,11 @@ export class BarRendererBase { } public x: number = 0; - public y: number = 0; + /** Renderer y is staff-relative and shared by every renderer in the staff: `staff.topPadding + staff.topOverflow`. */ + public get y(): number { + const s = this.staff; + return s ? s.topPadding + s.topOverflow : 0; + } public width: number = 0; public computedWidth: number = 0; public height: number = 0; @@ -137,6 +168,77 @@ export class BarRendererBase { public beatEffectsMinY = Number.NaN; public beatEffectsMaxY = Number.NaN; + private _barLocalSkyline: BarLocalSkyline | null = null; + private _preBeatLocalSkyline: BarLocalSkyline | null = null; + private _postBeatLocalSkyline: BarLocalSkyline | null = null; + + /** Per-bar local skyline of non-effect-band glyphs (renderer-local x). */ + public get barLocalSkyline(): BarLocalSkyline { + if (!this._barLocalSkyline) { + this._barLocalSkyline = new BarLocalSkyline( + 0, + Number.MAX_SAFE_INTEGER, + this.scoreRenderer.layout!.skylinePool + ); + } + return this._barLocalSkyline; + } + + /** Pre-beat glyphs' skyline contribution. Separate from {@link barLocalSkyline} so the latter's per-cycle reset doesn't wipe it. */ + public get preBeatLocalSkyline(): BarLocalSkyline { + if (!this._preBeatLocalSkyline) { + this._preBeatLocalSkyline = new BarLocalSkyline( + 0, + Number.MAX_SAFE_INTEGER, + this.scoreRenderer.layout!.skylinePool + ); + } + return this._preBeatLocalSkyline; + } + + /** Post-beat glyphs' skyline in post-beat-group-local x; shifted by {@link postBeatGroupOffset} when unioned. */ + public get postBeatLocalSkyline(): BarLocalSkyline { + if (!this._postBeatLocalSkyline) { + this._postBeatLocalSkyline = new BarLocalSkyline( + 0, + Number.MAX_SAFE_INTEGER, + this.scoreRenderer.layout!.skylinePool + ); + } + return this._postBeatLocalSkyline; + } + + public get postBeatGroupOffset(): number { + return this._postBeatGlyphs.x; + } + + /** Per-cycle reset of skylines and ties. Called from {@link doLayout}; not from {@link reLayout}. */ + public resetCycleState(): void { + this._barLocalSkyline?.reset(); + this._preBeatLocalSkyline?.reset(); + this._postBeatLocalSkyline?.reset(); + this._ties = []; + this.beatEffectsMinY = Number.NaN; + this.beatEffectsMaxY = Number.NaN; + } + + /** Emit a glyph's current bbox into {@link barLocalSkyline}. */ + public insertSkylineFromBbox(glyph: Glyph): void { + const xL = glyph.getBoundingBoxLeft(); + const xR = glyph.getBoundingBoxRight(); + if (xR <= xL) { + return; + } + const topY = glyph.getBoundingBoxTop(); + if (topY < 0) { + this.insertSkylineTop(xL, xR, -topY); + } + const bottomY = glyph.getBoundingBoxBottom(); + if (bottomY > this.height) { + this.insertSkylineBottom(xL, xR, bottomY - this.height); + } + } + public get topOverflow() { return this._contentTopOverflow + this.topEffects.height; } @@ -157,13 +259,6 @@ export class BarRendererBase { */ public isLinkedToPrevious: boolean = false; - /** - * Gets or sets whether this renderer can wrap to the next line - * or it needs to stay connected to the previous one. - * (e.g. when having double bar repeats we must not separate the 2 bars) - */ - public canWrap: boolean = true; - public get showMultiBarRest(): boolean { return true; } @@ -196,6 +291,15 @@ export class BarRendererBase { } } + public registerBeatEffectOverflowsForBeat(beat: Beat, minY: number, maxY: number): void { + this.registerBeatEffectOverflows(minY, maxY); + const container = this.getBeatContainer(beat); + if (container) { + const entry: BeatEffectOverflow = { minY, maxY }; + container.pendingEffectOverflows.push(entry); + } + } + public registerOverflowTop(topOverflow: number): boolean { topOverflow = Math.ceil(topOverflow); if (topOverflow > this._contentTopOverflow) { @@ -214,6 +318,37 @@ export class BarRendererBase { return false; } + /** Post-{@link scaleToWidth} only: also inserts into the bar-local skyline. */ + public registerOverflowRangeTop(xStart: number, xEnd: number, topHeight: number): boolean { + const changed = this.registerOverflowTop(topHeight); + if (topHeight > 0 && xEnd > xStart) { + this.barLocalSkyline.insertPlaced(StaffSide.Top, xStart, xEnd, topHeight, 0); + } + return changed; + } + + public registerOverflowRangeBottom(xStart: number, xEnd: number, bottomHeight: number): boolean { + const changed = this.registerOverflowBottom(bottomHeight); + if (bottomHeight > 0 && xEnd > xStart) { + this.barLocalSkyline.insertPlaced(StaffSide.Bottom, xStart, xEnd, bottomHeight, 0); + } + return changed; + } + + /** Emit a top-skyline segment into {@link barLocalSkyline}. */ + public insertSkylineTop(xStart: number, xEnd: number, topHeight: number): void { + if (topHeight > 0 && xEnd > xStart) { + this.barLocalSkyline.insertPlaced(StaffSide.Top, xStart, xEnd, topHeight, 0); + } + } + + /** Emit a bottom-skyline segment into {@link barLocalSkyline}. */ + public insertSkylineBottom(xStart: number, xEnd: number, bottomHeight: number): void { + if (bottomHeight > 0 && xEnd > xStart) { + this.barLocalSkyline.insertPlaced(StaffSide.Bottom, xStart, xEnd, bottomHeight, 0); + } + } + /** * The fixed-overhead width of this renderer: glyphs that do not stretch when * the bar is scaled (clef, key signature, time signature, barlines, courtesy @@ -227,21 +362,57 @@ export class BarRendererBase { public scaleToWidth(width: number): void { // preBeat and postBeat glyphs do not get resized const containerWidth: number = width - this._preBeatGlyphs.width - this._postBeatGlyphs.width; + + // Re-emit scale-dependent segments. pre/postBeatLocalSkyline are + // owned by calculateOverflows and untouched here. + this.barLocalSkyline.reset(); + + // Spring-X about to be re-laid-out, so cached BeamingHelperDrawInfo is stale. + for (const v of this.helpers.beamHelpers) { + for (const h of v) { + h.invalidateDrawingInfos(); + } + } + this.voiceContainer.scaleToWidth(containerWidth); for (const v of this.helpers.beamHelpers) { for (const h of v) { - h.alignWithBeats(); + this.emitHelperSkyline(h); } } this._postBeatGlyphs.x = this._preBeatGlyphs.x + this._preBeatGlyphs.width + containerWidth; this.width = width; + // `EffectInfo.onAlignGlyphs` overrides must be max-of-idempotent; + // shared `_sharedLayoutData` is reset per system in + // `StaffSystem.resetAllStavesSharedLayoutData`. this.topEffects.alignGlyphs(); this.bottomEffects.alignGlyphs(); + + const preBeatGlyphs = this._preBeatGlyphs.glyphs; + if (preBeatGlyphs) { + for (const g of preBeatGlyphs) { + g.populateSkyline(); + } + } + this.topEffects.populateSkyline(); + this.bottomEffects.populateSkyline(); + + this.emitSubclassBarLocalSkyline(); + + // Geometry is now settled; cross-renderer chain walks during + // finalizeStaff can rely on this flag. Reset by {@link afterReverted}. + this.isFinalized = true; } + protected emitHelperSkyline(_h: BeamingHelper): void {} + + public emitBeatSkyline(_beatContainer: BeatContainerGlyphBase): void {} + + protected emitSubclassBarLocalSkyline(): void {} + public get resources(): RenderingResources { return this.settings.display.resources; } @@ -277,14 +448,15 @@ export class BarRendererBase { const container = this.voiceContainer; container.registerLayoutingInfo(info); + this.topEffects.registerLayoutingInfo(info); + this.bottomEffects.registerLayoutingInfo(info); + const postSize: number = this._postBeatGlyphs.width; if (info.postBeatSize < postSize) { info.postBeatSize = postSize; } } - private _appliedLayoutingInfo: number = 0; - public afterReverted() { this.staff = undefined; this.registerMultiSystemSlurs(undefined); @@ -292,20 +464,19 @@ export class BarRendererBase { } public afterStaffBarReverted() { - this.topEffects.afterStaffBarReverted(); - this.bottomEffects.afterStaffBarReverted(); + // Band internals (placedMagnitude/y/publishedSpans) are recomputed + // by the next finalizeStaff cycle before paint reads them. + this.topEffects.height = 0; + this.bottomEffects.height = 0; this._registerStaffOverflow(); } - public applyLayoutingInfo(): boolean { - if (this._appliedLayoutingInfo >= this.layoutingInfo.version) { - return false; - } - - this.topEffects.resetEffectBandSizingInfo(); - this.bottomEffects.resetEffectBandSizingInfo(); - - this._appliedLayoutingInfo = this.layoutingInfo.version; + /** + * Pull the current {@link BarLayoutingInfo} broker state into this + * renderer's positions. Value-idempotent on a stable broker; callers + * must gate themselves to skip unchanged bars. + */ + public applyLayoutingInfo(): void { // if we need additional space in the preBeat group we simply // add a new spacer this._preBeatGlyphs.width = this.layoutingInfo.preBeatSize; @@ -315,17 +486,14 @@ export class BarRendererBase { container.x = this._preBeatGlyphs.x + this._preBeatGlyphs.width; container.applyLayoutingInfo(this.layoutingInfo); - // on the post glyphs we add the spacing before all other glyphs - this._postBeatGlyphs.x = Math.floor(container.x + container.width); + // `_postBeatGlyphs.x` is written once at end of {@link scaleToWidth}; + // compute locally here without touching the field. this._postBeatGlyphs.width = this.layoutingInfo.postBeatSize; - this.width = Math.ceil(this._postBeatGlyphs.x + this._postBeatGlyphs.width); + const postBeatX = Math.floor(container.x + container.width); + this.width = Math.ceil(postBeatX + this._postBeatGlyphs.width); this.computedWidth = this.width; - this.topEffects.sizeAndAlignEffectBands(); - this.bottomEffects.sizeAndAlignEffectBands(); this._registerStaffOverflow(); - - return true; } public isFinalized: boolean = false; @@ -351,63 +519,74 @@ export class BarRendererBase { this._multiSystemSlurs = ties; } - private _finalizeTies(ties: Iterable, barTop: number, barBottom: number): boolean { - let didChangeOverflows = false; + /** Republish each effect band's cross-renderer chain spans. */ + public finalizeEffectBandSpans(): void { + this.topEffects.finalizeChainSpans(); + this.bottomEffects.finalizeChainSpans(); + } + + public finalizeOwnedTies(): void { + this._emitTies(this._ties); + if (this._multiSystemSlurs) { + this._emitTies(this._multiSystemSlurs); + } + } + + private _emitTies(ties: Iterable): void { + const staffRenderers = this.staff!.barRenderers; + const startIndex = this.index; for (const t of ties) { const tie = t as unknown as Glyph; tie.doLayout(); - if (t.checkForOverflow) { - // NOTE: Ties are aligned on staff level, need to subtract the bar position - const tieTop = tie.getBoundingBoxTop(); - const tieBottom = tie.getBoundingBoxBottom(); + if (!t.checkForOverflow) { + continue; + } + + const tieTop = t.getBoundingBoxTop(); + const tieBottom = t.getBoundingBoxBottom(); + const tieTopOverflow = tieTop < 0 ? -tieTop : 0; - const bottomOverflow = tieBottom - barBottom; - if (bottomOverflow > 0) { - if (this.registerOverflowBottom(bottomOverflow)) { - didChangeOverflows = true; - } + const tieLeftStaff = t.getBoundingBoxLeft(); + const tieRightStaff = t.getBoundingBoxRight(); + + for (let i = startIndex; i < staffRenderers.length; i++) { + const target = staffRenderers[i]; + if (target.x >= tieRightStaff) { + break; } - const topOverflow = tieTop - barTop; - if (topOverflow < 0) { - if (this.registerOverflowTop(topOverflow * -1)) { - didChangeOverflows = true; + const targetXStart = target.x; + const targetXEnd = target.x + target.width; + const xStartStaff = Math.max(targetXStart, tieLeftStaff); + const xEndStaff = Math.min(targetXEnd, tieRightStaff); + if (xEndStaff <= xStartStaff) { + continue; + } + const xStart = xStartStaff - targetXStart; + const xEnd = xEndStaff - targetXStart; + const tieBottomOverflow = tieBottom - target.height; + + if (target === this) { + if (tieTopOverflow > 0) { + if (target.registerOverflowRangeTop(xStart, xEnd, tieTopOverflow)) { + target.markTiesDirty(); + } + } + if (tieBottomOverflow > 0) { + if (target.registerOverflowRangeBottom(xStart, xEnd, tieBottomOverflow)) { + target.markTiesDirty(); + } + } + } else { + if (tieTopOverflow > 0) { + target.barLocalSkyline.insertPlaced(StaffSide.Top, xStart, xEnd, tieTopOverflow, 0); + } + if (tieBottomOverflow > 0) { + target.barLocalSkyline.insertPlaced(StaffSide.Bottom, xStart, xEnd, tieBottomOverflow, 0); } } } } - return didChangeOverflows; - } - - public finalizeRenderer(): boolean { - this.isFinalized = true; - - let didChangeOverflows = false; - // allow spacing to be used for tie overflows - const barTop = this.y; - const barBottom = this.y + this.height; - - if (this._finalizeTies(this._ties, barTop, barBottom)) { - didChangeOverflows = true; - } - - const multiSystemSlurs = this._multiSystemSlurs; - if (multiSystemSlurs && this._finalizeTies(multiSystemSlurs, barTop, barBottom)) { - didChangeOverflows = true; - } - - const topHeightChanged = this.topEffects.finalizeEffects(); - const bottomHeightChanged = this.bottomEffects.finalizeEffects(); - if (topHeightChanged || bottomHeightChanged) { - didChangeOverflows = true; - } - - if (didChangeOverflows) { - this.updateSizes(); - this._registerStaffOverflow(); - } - - return didChangeOverflows; } private _registerStaffOverflow() { @@ -415,21 +594,31 @@ export class BarRendererBase { this.staff!.registerOverflowBottom(this.bottomOverflow); } + /** Public wrapper for `_registerStaffOverflow`. */ + public registerStaffOverflows(): void { + this._registerStaffOverflow(); + } + + /** + * Public wrapper for `updateSizes`. Cannot widen `updateSizes` directly + * because `LineBarRenderer.updateSizes` is `protected override` and the + * transpiler does not consistently widen visibility across overrides. + */ + public refreshSizes(): void { + this.updateSizes(); + } + public doLayout(): void { if (!this.bar) { return; } this.helpers.initialize(); - this._ties = []; this._preBeatGlyphs.renderer = this; this.voiceContainer.renderer = this; this._postBeatGlyphs.renderer = this; this.topEffects.doLayout(); this.bottomEffects.doLayout(); - - if (this.bar.simileMark === SimileMark.SecondOfDouble) { - this.canWrap = false; - } + this.resetCycleState(); this.createPreBeatGlyphs(); this.createBeatGlyphs(); @@ -437,10 +626,6 @@ export class BarRendererBase { this._registerLayoutingInfo(); - // registering happened during creation - this.topEffects.sizeAndAlignEffectBands(false); - this.bottomEffects.sizeAndAlignEffectBands(false); - this.updateSizes(); // finish up all helpers @@ -456,33 +641,20 @@ export class BarRendererBase { } protected calculateOverflows(_rendererTop: number, rendererBottom: number) { + // Re-emit pre/post-beat skylines from scratch each pass. Pre-beat + // group x = 0 so its local x equals bar-local x; post-beat x is + // not final until scaleToWidth, so the staff-skyline union shifts + // it later. + this.preBeatLocalSkyline.reset(); + this.postBeatLocalSkyline.reset(); + const preBeatGlyphs = this._preBeatGlyphs.glyphs; if (preBeatGlyphs) { - for (const g of preBeatGlyphs) { - const topY = g.getBoundingBoxTop(); - if (topY < 0) { - this.registerOverflowTop(topY * -1); - } - - const bottomY = g.getBoundingBoxBottom(); - if (bottomY > rendererBottom) { - this.registerOverflowBottom(bottomY - rendererBottom); - } - } + this._emitGroupOverflows(preBeatGlyphs, this.preBeatLocalSkyline, rendererBottom); } const postBeatGlyphs = this._postBeatGlyphs.glyphs; if (postBeatGlyphs) { - for (const g of postBeatGlyphs) { - const topY = g.getBoundingBoxTop(); - if (topY < 0) { - this.registerOverflowTop(topY * -1); - } - - const bottomY = g.getBoundingBoxBottom(); - if (bottomY > rendererBottom) { - this.registerOverflowBottom(bottomY - rendererBottom); - } - } + this._emitGroupOverflows(postBeatGlyphs, this.postBeatLocalSkyline, rendererBottom); } const v = this.voiceContainer; @@ -507,6 +679,34 @@ export class BarRendererBase { } } + /** Emit per-glyph overflow into the given group skyline. Shared by pre- and post-beat groups. */ + private _emitGroupOverflows(glyphs: Glyph[], skyline: BarLocalSkyline, rendererBottom: number): void { + for (const g of glyphs) { + const topY = g.getBoundingBoxTop(); + const bottomY = g.getBoundingBoxBottom(); + const topOver = topY < 0; + const bottomOver = bottomY > rendererBottom; + if (!topOver && !bottomOver) { + continue; + } + const xL = g.getBoundingBoxLeft(); + const xR = g.getBoundingBoxRight(); + const hasExtent = xR > xL; + if (topOver) { + this.registerOverflowTop(topY * -1); + if (hasExtent) { + skyline.insertPlaced(StaffSide.Top, xL, xR, topY * -1, 0); + } + } + if (bottomOver) { + this.registerOverflowBottom(bottomY - rendererBottom); + if (hasExtent) { + skyline.insertPlaced(StaffSide.Bottom, xL, xR, bottomY - rendererBottom, 0); + } + } + } + } + protected updateSizes(): void { this.staff!.registerStaffTop(0); @@ -515,13 +715,6 @@ export class BarRendererBase { this.width = Math.ceil(this._postBeatGlyphs.x + this._postBeatGlyphs.width); - const topHeightChanged = this.topEffects.updateEffectBandHeights(); - const bottomHeightChanged = this.bottomEffects.updateEffectBandHeights(); - if (topHeightChanged || bottomHeightChanged) { - this._registerStaffOverflow(); - } - - this.height += this.layoutingInfo.height; this.height = Math.ceil(this.height); this.staff!.registerStaffBottom(this.height); @@ -679,15 +872,14 @@ export class BarRendererBase { } public reLayout(): void { - this.topEffects.reLayout(); - this.bottomEffects.reLayout(); + this.topEffects.height = 0; + this.bottomEffects.height = 0; this.updateSizes(); // there are some glyphs which are shown only for renderers at the line start, so we simply recreate them // but we only need to recreate them for the renderers that were the first of the line or are now the first of the line if ((this.wasFirstOfStaff && !this.isFirstOfStaff) || (!this.wasFirstOfStaff && this.isFirstOfStaff)) { this.recreatePreBeatGlyphs(); - this._postBeatGlyphs.doLayout(); } this._registerLayoutingInfo(); diff --git a/packages/alphatab/src/rendering/BarRendererFactory.ts b/packages/alphatab/src/rendering/BarRendererFactory.ts index c5d9bf268..969e01bfb 100644 --- a/packages/alphatab/src/rendering/BarRendererFactory.ts +++ b/packages/alphatab/src/rendering/BarRendererFactory.ts @@ -48,6 +48,10 @@ export enum EffectBandMode { export interface EffectBandInfo { mode: EffectBandMode; effect: EffectInfo; + /** + * Visual-stack position within {@link EffectInfo.placementCategory}. + * Higher value = closer to staff. Defaults to the declaration index. + */ order?: number; shouldCreate?: (staff: Staff) => boolean; } diff --git a/packages/alphatab/src/rendering/EffectBand.ts b/packages/alphatab/src/rendering/EffectBand.ts index e408d8268..417366fb8 100644 --- a/packages/alphatab/src/rendering/EffectBand.ts +++ b/packages/alphatab/src/rendering/EffectBand.ts @@ -3,13 +3,26 @@ 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 type { EffectBandContainer } from '@coderline/alphatab/rendering/EffectBandContainer'; -import type { EffectBandSlot } from '@coderline/alphatab/rendering/EffectBandSlot'; import { EffectBarGlyphSizing } from '@coderline/alphatab/rendering/EffectBarGlyphSizing'; import 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'; +import type { BarLayoutingInfo } from '@coderline/alphatab/rendering/staves/BarLayoutingInfo'; import { ElementStyleHelper } from '@coderline/alphatab/rendering/utils/ElementStyleHelper'; +/** + * Renderer-local x-range used by {@link EffectBand.computeLocalXRange} and + * {@link EffectSystemPlacement} to query and insert into the staff skyline. + * + * @record + * @internal + */ +export interface EffectBandXRange { + xStart: number; + xEnd: number; +} + /** * @internal */ @@ -19,7 +32,6 @@ export class EffectBand extends Glyph { private _container: EffectBandContainer; public isEmpty: boolean = true; - public previousBand: EffectBand | null = null; public isLinkedToPrevious: boolean = false; public firstBeat: Beat | null = null; public lastBeat: Beat | null = null; @@ -27,27 +39,188 @@ export class EffectBand extends Glyph { public originalHeight: number = 0; public voice: Voice; public info: EffectInfo; - public slot: EffectBandSlot | null = null; - public constructor(voice: Voice, info: EffectInfo, container: EffectBandContainer) { + public placedMagnitude: number = 0; + + /** + * Stable prefix of the {@link EffectSystemPlacement} sort key. Final key + * is `_stableSortKey + renderer.index` (renderer.index can change after + * construction when bars are moved between staves). Bit layout: + * placementCategory * 2^40 + (0xFFFF - order) * 2^24 + voice.index * 2^20 + * (order is inverted so higher `order` sorts first). + */ + private _stableSortKey: number = 0; + + /** 4-key sort: placementCategory asc, order desc, voice.index asc, renderer.index asc. */ + public get sortKey(): number { + return this._stableSortKey + this.renderer.index; + } + + /** + * Renderer-local x-range cache. The base snapshot is the union of glyph + * paint extents (and `[0, renderer.width)` for FullBar); the live fields + * start equal to the base and are widened by {@link publishSpanRange} + * when a {@link GroupedEffectGlyph} publishes its cross-renderer span. + * {@link clearPublishedSpans} resets live to base. + */ + private _xRangeMin: number = 0; + private _xRangeMax: number = 0; + private _xRangeFound: boolean = false; + private _xRangeBaseMin: number = 0; + private _xRangeBaseMax: number = 0; + private _xRangeBaseFound: boolean = false; + private _xRangeBaseDirty: boolean = true; + + public get container(): EffectBandContainer { + return this._container; + } + + /** Chain heads in this band, walked by {@link finalizeChainSpans}. */ + private _chainHeads: GroupedEffectGlyph[] = []; + + public registerChainHead(head: GroupedEffectGlyph): void { + this._chainHeads.push(head); + } + + /** Republishes each chain head's cross-renderer xEnd. Called once per band after the staff is finalized. */ + public finalizeChainSpans(): void { + this.clearPublishedSpans(); + for (let i = 0, n = this._chainHeads.length; i < n; i++) { + this._chainHeads[i].publishChainSpan(); + } + } + + /** Dispatches {@link Glyph.populateSkyline} on every glyph the band owns. */ + public override populateSkyline(): void { + for (let v = 0; v < this._uniqueEffectGlyphs.length; v++) { + const voiceGlyphs = this._uniqueEffectGlyphs[v]; + for (let i = 0, n = voiceGlyphs.length; i < n; i++) { + voiceGlyphs[i].populateSkyline(); + } + } + } + + public publishSpanRange(xStart: number, xEnd: number): void { + if (this._xRangeBaseDirty) { + this._refreshXRangeBase(); + } + if (this._xRangeFound) { + if (xStart < this._xRangeMin) { + this._xRangeMin = xStart; + } + if (xEnd > this._xRangeMax) { + this._xRangeMax = xEnd; + } + } else { + this._xRangeMin = xStart; + this._xRangeMax = xEnd; + this._xRangeFound = true; + } + } + + public clearPublishedSpans(): void { + // Defer base recomputation if stale (alignGlyphs invalidated it). + if (this._xRangeBaseDirty) { + this._xRangeMin = 0; + this._xRangeMax = 0; + this._xRangeFound = false; + } else { + this._xRangeMin = this._xRangeBaseMin; + this._xRangeMax = this._xRangeBaseMax; + this._xRangeFound = this._xRangeBaseFound; + } + } + + private _refreshXRangeBase(): void { + let min = 0; + let max = 0; + let found = false; + if (this.info.sizingMode === EffectBarGlyphSizing.FullBar) { + min = 0; + max = this.renderer.width; + found = true; + } + for (const v of this._uniqueEffectGlyphs) { + for (const g of v) { + const left = g.getBoundingBoxLeft(); + const right = g.getBoundingBoxRight(); + if (!found) { + min = left; + max = right; + found = true; + } else { + if (left < min) { + min = left; + } + if (right > max) { + max = right; + } + } + } + } + this._xRangeBaseMin = min; + this._xRangeBaseMax = max; + this._xRangeBaseFound = found; + this._xRangeMin = min; + this._xRangeMax = max; + this._xRangeFound = found; + this._xRangeBaseDirty = false; + } + + public constructor( + voice: Voice, + info: EffectInfo, + container: EffectBandContainer, + renderer: BarRendererBase, + order: number + ) { super(0, 0); this.voice = voice; this.info = info; this._container = container; + this.renderer = renderer; + const clampedOrder = order < 0 ? 0 : order > 0xffff ? 0xffff : order; + this._stableSortKey = + info.placementCategory * 1099511627776 + // 2^40 + (0xffff - clampedOrder) * 16777216 + // 2^24 + voice.index * 1048576; // 2^20 } - public *iterateAllGlyphs() { - for (const v of this._effectGlyphs) { - for (const g of v.values()) { - yield g; - } - } + /** Per-voice insertion-ordered view of every glyph the band owns. Read-only; band owns lifetime. */ + public get glyphsByVoice(): EffectGlyph[][] { + return this._uniqueEffectGlyphs; } public finalizeBand() { this.info.finalizeBand(this); } + public registerLayoutingInfo(layoutings: BarLayoutingInfo): void { + if (!this.info.contributesToBeatSpacing) { + return; + } + for (let v = 0; v < this._uniqueEffectGlyphs.length; v++) { + const voiceGlyphs = this._uniqueEffectGlyphs[v]; + for (let i = 0, n = voiceGlyphs.length; i < n; i++) { + const glyph = voiceGlyphs[i]; + const beat = glyph.beat; + if (!beat) { + continue; + } + const container = this.renderer.getBeatContainer(beat); + if (!container) { + continue; + } + // glyph.x is 0 here; set later by `_alignGlyph`. + const preBeat = Math.max(0, -glyph.getBoundingBoxLeft()); + const postBeat = Math.max(0, glyph.getBoundingBoxRight()); + if (preBeat > 0 || postBeat > 0) { + layoutings.addBeatSpring(container, preBeat, postBeat); + } + } + } + } + public override doLayout(): void { super.doLayout(); for (let i: number = 0; i < this.renderer.bar.voices.length; i++) { @@ -105,9 +278,26 @@ export class EffectBand extends Glyph { g = this.info.createNewGlyph(this.renderer, b); g.renderer = this.renderer; g.beat = b; + g.band = this; g.doLayout(); this._effectGlyphs[b.voice.index].set(b.index, g); this._uniqueEffectGlyphs[b.voice.index].push(g); + // FullBar chain link so continuation bars stay at one magnitude. + if (this.renderer.index > 0 && b.index === 0) { + const previousContainer = this._container.previousContainer; + const previousBand = previousContainer?.getBand(b.voice, this.info.effectId); + if (previousBand && !previousBand.isEmpty) { + const prevBar = b.voice.bar.previousBar; + const prevVoice = prevBar?.voices[b.voice.index]; + const prevLastBeat = + prevVoice && prevVoice.beats.length > 0 + ? prevVoice.beats[prevVoice.beats.length - 1] + : null; + if (prevLastBeat && this.info.canExpand(prevLastBeat, b)) { + this.isLinkedToPrevious = true; + } + } + } return g; case EffectBarGlyphSizing.SinglePreBeat: case EffectBarGlyphSizing.SingleOnBeat: @@ -115,6 +305,7 @@ export class EffectBand extends Glyph { g = this.info.createNewGlyph(this.renderer, b); g.renderer = this.renderer; g.beat = b; + g.band = this; g.doLayout(); this._effectGlyphs[b.voice.index].set(b.index, g); this._uniqueEffectGlyphs[b.voice.index].push(g); @@ -157,6 +348,14 @@ export class EffectBand extends Glyph { newGlyph.previousGlyph = prevEffect; // mark renderers as linked for consideration when layouting the renderers (line breaking, partial breaking) this.isLinkedToPrevious = true; + // 1->2 transition: track the chain head so its span gets republished after staff finalize. + if ( + prevEffect.previousGlyph === null && + prevEffect.band && + prevEffect instanceof GroupedEffectGlyph + ) { + prevEffect.band.registerChainHead(prevEffect); + } } return newGlyph; } @@ -173,11 +372,6 @@ export class EffectBand extends Glyph { public override paint(cx: number, cy: number, canvas: ICanvas): void { super.paint(cx, cy, canvas); - // const c = canvas.color; - // canvas.color = Color.random(); - // canvas.fillRect(cx + this.x, cy + this.y, this.renderer.width, this.slot!.shared.height); - // canvas.color = c; - for (let i: number = 0, j: number = this._uniqueEffectGlyphs.length; i < j; i++) { const v: EffectGlyph[] = this._uniqueEffectGlyphs[i]; for (let k: number = 0, l: number = v.length; k < l; k++) { @@ -189,15 +383,42 @@ export class EffectBand extends Glyph { } public alignGlyphs(): void { - for (let v: number = 0; v < this._effectGlyphs.length; v++) { - for (const beatIndex of this._effectGlyphs[v].keys()) { - const g = this.renderer.bar.voices[v].beats[beatIndex]; - this._alignGlyph(this.info.sizingMode, g); + // x-range is rebuilt lazily after extents settle. + this._xRangeBaseDirty = true; + this._xRangeMin = 0; + this._xRangeMax = 0; + this._xRangeFound = false; + + for (let v: number = 0; v < this._uniqueEffectGlyphs.length; v++) { + const voiceGlyphs = this._uniqueEffectGlyphs[v]; + for (let i = 0, n = voiceGlyphs.length; i < n; i++) { + this._alignGlyph(this.info.sizingMode, voiceGlyphs[i].beat!); } } this.info.onAlignGlyphs(this); } + /** + * Writes the renderer-local x range into `out`. Unions glyph paint + * extents (effect glyphs often have width=0, so x/width is not enough) + * with cross-renderer spans from {@link publishSpanRange}. Returns + * `false` when the band has no usable range. + */ + public computeLocalXRange(out: EffectBandXRange): boolean { + if (this.isEmpty) { + return false; + } + if (this._xRangeBaseDirty) { + this._refreshXRangeBase(); + } + if (!this._xRangeFound || this._xRangeMax < this._xRangeMin) { + return false; + } + out.xStart = this._xRangeMin; + out.xEnd = this._xRangeMax; + 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)!; diff --git a/packages/alphatab/src/rendering/EffectBandContainer.ts b/packages/alphatab/src/rendering/EffectBandContainer.ts index b0271b22f..e10289a85 100644 --- a/packages/alphatab/src/rendering/EffectBandContainer.ts +++ b/packages/alphatab/src/rendering/EffectBandContainer.ts @@ -3,31 +3,57 @@ import type { ICanvas } from '@coderline/alphatab/platform/ICanvas'; import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; import type { EffectBandInfo } from '@coderline/alphatab/rendering/BarRendererFactory'; import { EffectBand } from '@coderline/alphatab/rendering/EffectBand'; -import { EffectBandSizingInfo } from '@coderline/alphatab/rendering/EffectBandSizingInfo'; -import type { EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; +import type { BarLayoutingInfo } from '@coderline/alphatab/rendering/staves/BarLayoutingInfo'; /** - * Wraps the whole effect band staff for having two times the same container - * holding bands (one for the top effects, one for the bottom effects) + * Per-(voice × effect) {@link EffectBand} list for one side of a bar + * renderer. Owns band lifecycle, glyph alignment, painting. Placement + * is delegated to {@link EffectSystemPlacement}. * @internal */ export class EffectBandContainer { private _bands: EffectBand[] = []; - private _bandLookup: Map = new Map(); - private _effectBandSizingInfo: EffectBandSizingInfo | null = null; - private _effectInfosSortOrder: Map = new Map(); + /** Per-voice (effectId → band) lookup; nested to avoid string-key allocation in `_createOrResizeGlyph`. */ + private _bandLookup: Map> = new Map(); public height: number = 0; public infos!: EffectBandInfo[]; private _renderer: BarRendererBase; private _isTopContainer: boolean; + public get bands(): EffectBand[] { + return this._bands; + } + + public get isTopContainer(): boolean { + return this._isTopContainer; + } + public alignGlyphs() { for (const effectBand of this._bands) { + effectBand.resetHeight(); effectBand.alignGlyphs(); } } + public registerLayoutingInfo(layoutings: BarLayoutingInfo): void { + for (const band of this._bands) { + band.registerLayoutingInfo(layoutings); + } + } + + public finalizeChainSpans(): void { + for (const band of this._bands) { + band.finalizeChainSpans(); + } + } + + public populateSkyline(): void { + for (const band of this._bands) { + band.populateSkyline(); + } + } + public get previousContainer(): EffectBandContainer | undefined { return this._renderer.index === 0 ? undefined @@ -37,7 +63,12 @@ export class EffectBandContainer { } public get isLinkedToPreviousRenderer() { - return this._bands.some(b => b.isLinkedToPrevious); + for (let i = 0, n = this._bands.length; i < n; i++) { + if (this._bands[i].isLinkedToPrevious) { + return true; + } + } + return false; } public constructor(renderer: BarRendererBase, isTopContainer: boolean) { @@ -45,124 +76,47 @@ export class EffectBandContainer { this._isTopContainer = isTopContainer; } - public reLayout() { - this.resetEffectBandSizingInfo(); - this.sizeAndAlignEffectBands(); - } - - public afterStaffBarReverted() { - this.resetEffectBandSizingInfo(); - this.sizeAndAlignEffectBands(); - } - public createVoiceGlyphs(voice: Voice) { - let i = 0; const renderer = this._renderer; const notationSettings = renderer.settings.notation; - for (const info of this.infos) { + for (let i = 0; i < this.infos.length; i++) { + const info = this.infos[i]; if (!notationSettings.isNotationElementVisible(info.effect.notationElement)) { continue; } + // Fallback: declaration index in the staff's infos list. + const order = info.order ?? i; + let band: EffectBand | undefined = undefined; - this._effectInfosSortOrder.set(info.effect, info.order ?? i); for (const b of voice.beats) { // lazy create band to avoid creating and managing bands for all events // even if only a few exist if (!band && EffectBand.shouldCreateGlyph(b, info.effect, renderer)) { - band = new EffectBand(voice, info.effect, this); - band.renderer = this._renderer; + band = new EffectBand(voice, info.effect, this, this._renderer, order); band.doLayout(); this._bands.push(band); - this._bandLookup.set(`${voice.index}.${info.effect.effectId}`, band); + let perVoice = this._bandLookup.get(voice.index); + if (!perVoice) { + perVoice = new Map(); + this._bandLookup.set(voice.index, perVoice); + } + perVoice.set(info.effect.effectId, band); } if (band !== undefined) { band.createGlyph(b); } } - i++; } } public doLayout() { - this._effectInfosSortOrder.clear(); - - this._bands = []; - this._bandLookup = new Map(); - this.resetEffectBandSizingInfo(); - } - - public resetEffectBandSizingInfo() { - if (this._renderer.index > 0) { - this._effectBandSizingInfo = this.previousContainer!._effectBandSizingInfo; - } else { - // try reusing current one to avoid GC pressure - if (this._effectBandSizingInfo && this._effectBandSizingInfo.owner === this) { - this._effectBandSizingInfo.reset(); - } else { - this._effectBandSizingInfo = new EffectBandSizingInfo(this); - } - } - } - - public finalizeEffects() { - return this._updateEffectBandHeights(true); - } - - public updateEffectBandHeights(): boolean { - return this._updateEffectBandHeights(false); - } - - private _updateEffectBandHeights(finalize: boolean): boolean { - if (!this._effectBandSizingInfo) { - return false; - } - - let y: number = 0; - // TODO. activate padding - // const paddingTop = this._isTopContainer ? 0 : this._renderer.settings.display.effectBandPaddingBottom; - // const paddingBottom = this._isTopContainer ? this._renderer.settings.display.effectBandPaddingBottom : 0; - const paddingTop = 0; - const paddingBottom = this._renderer.settings.display.effectBandPaddingBottom; - - for (const slot of this._effectBandSizingInfo.slots) { - slot.shared.y = y; - for (const band of slot.bands) { - y += paddingTop; - band.y = y; - if (finalize) { - band.finalizeBand(); - } - band.height = slot.shared.height; - } - y += slot.shared.height + paddingBottom; - } - y = Math.ceil(y); - - if (y !== this.height) { - this.height = y; - return true; - } - return false; - } - - public sizeAndAlignEffectBands(register: boolean = true) { - for (const effectBand of this._bands) { - effectBand.resetHeight(); - effectBand.alignGlyphs(); - if (register && !effectBand.isEmpty) { - // find a slot that ended before the start of the band - this._effectBandSizingInfo!.register(effectBand); - } - } - - // if we're registering new slots for the effects, we need to sort the - // slots afterwards to keep the registered order. we don't want the "first occured" effect on top but the "first registered" - if (register) { - this._effectBandSizingInfo!.sortSlots(this._effectInfosSortOrder); - } + // splice() instead of `.length = 0`: transpile-safe array clear. + this._bands.splice(0, this._bands.length); + this._bandLookup.clear(); + this.height = 0; } public paint(cx: number, cy: number, canvas: ICanvas) { @@ -176,10 +130,10 @@ export class EffectBandContainer { } public getBand(voice: Voice, effectId: string): EffectBand | null { - const id: string = `${voice.index}.${effectId}`; - if (this._bandLookup.has(id)) { - return this._bandLookup.get(id)!; + const perVoice = this._bandLookup.get(voice.index); + if (!perVoice) { + return null; } - return null; + return perVoice.get(effectId) ?? null; } } diff --git a/packages/alphatab/src/rendering/EffectBandSizingInfo.ts b/packages/alphatab/src/rendering/EffectBandSizingInfo.ts deleted file mode 100644 index 37d37df06..000000000 --- a/packages/alphatab/src/rendering/EffectBandSizingInfo.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { EffectBand } from '@coderline/alphatab/rendering/EffectBand'; -import type { EffectBandContainer } from '@coderline/alphatab/rendering/EffectBandContainer'; -import { EffectBandSlot } from '@coderline/alphatab/rendering/EffectBandSlot'; -import type { EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; - -/** - * @internal - */ -export class EffectBandSizingInfo { - private _effectSlot: Map; - private _assignedSlots: Map; - public slots: EffectBandSlot[]; - public owner: EffectBandContainer; - - public constructor(owner: EffectBandContainer) { - this.slots = []; - this._effectSlot = new Map(); - this._assignedSlots = new Map(); - this.owner = owner; - } - - public reset() { - this._effectSlot.clear(); - this._assignedSlots.clear(); - this.slots = []; - } - - public getOrCreateSlot(band: EffectBand): EffectBandSlot { - // check if we have already a slot - if (this._assignedSlots.has(band)) { - return this._assignedSlots.get(band)!; - } - - // first check preferrable slot depending on type - if (this._effectSlot.has(band.info.effectId)) { - const slot: EffectBandSlot = this._effectSlot.get(band.info.effectId)!; - if (slot.canBeUsed(band)) { - this._assignedSlots.set(band, slot); - return slot; - } - } - // find any slot that can be used - for (const slot of this.slots) { - if (slot.canBeUsed(band)) { - this._assignedSlots.set(band, slot); - return slot; - } - } - // create a new slot if required - const newSlot: EffectBandSlot = new EffectBandSlot(); - this.slots.push(newSlot); - this._assignedSlots.set(band, newSlot); - - return newSlot; - } - - public register(effectBand: EffectBand): void { - const freeSlot: EffectBandSlot = this.getOrCreateSlot(effectBand); - freeSlot.update(effectBand); - this._effectSlot.set(effectBand.info.effectId, freeSlot); - } - - public sortSlots(sortOrder: Map) { - for (const s of this.slots) { - s.bands.sort((a, b) => { - const ai = sortOrder.get(a.info)!; - const bi = sortOrder.get(b.info)!; - return ai - bi; - }); - } - - this.slots.sort((a, b) => { - const ai = sortOrder.get(a.bands[0].info)!; - const bi = sortOrder.get(b.bands[0].info)!; - return ai - bi; - }); - } -} diff --git a/packages/alphatab/src/rendering/EffectBandSlot.ts b/packages/alphatab/src/rendering/EffectBandSlot.ts deleted file mode 100644 index 5fd62634d..000000000 --- a/packages/alphatab/src/rendering/EffectBandSlot.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { Beat } from '@coderline/alphatab/model/Beat'; -import type { EffectBand } from '@coderline/alphatab/rendering/EffectBand'; - -/** - * @internal - */ -export class EffectBandSlotShared { - public uniqueEffectId: string | null = null; - public y: number = 0; - public height: number = 0; - public firstBeat: Beat | null = null; - public lastBeat: Beat | null = null; -} - -/** - * @internal - */ -export class EffectBandSlot { - public bands: EffectBand[]; - - public shared: EffectBandSlotShared; - - public constructor() { - this.bands = []; - this.shared = new EffectBandSlotShared(); - } - - public update(effectBand: EffectBand): void { - // lock band to particular effect if needed - if (!effectBand.info.canShareBand) { - this.shared.uniqueEffectId = effectBand.info.effectId; - } - effectBand.slot = this; - this.bands.push(effectBand); - if (effectBand.height > this.shared.height) { - this.shared.height = effectBand.height; - } - if (!this.shared.firstBeat || effectBand.firstBeat!.isBefore(this.shared.firstBeat)) { - this.shared.firstBeat = effectBand.firstBeat; - } - if (!this.shared.lastBeat || effectBand.lastBeat!.isAfter(this.shared.lastBeat)) { - this.shared.lastBeat = effectBand.lastBeat; - } - } - - public canBeUsed(band: EffectBand): boolean { - const canShareBand = - (!this.shared.uniqueEffectId && band.info.canShareBand) || - band.info.effectId === this.shared.uniqueEffectId; - if (!canShareBand) { - return false; - } - - // first beat in slot - if (!this.shared.firstBeat) { - return true; - } - - // beat is already added and this is an "extended" band connecting to the previous bar - if(this.shared.lastBeat === band.firstBeat){ - return true; - } - - // newly added band is after current beat - if (this.shared.lastBeat!.isBefore(band.firstBeat!)) { - return true; - } - - // historical case, doesn't make much sense, but let's keep it for now - if (this.shared.lastBeat!.isBefore(this.shared.firstBeat)) { - return true; - } - - return false; - } -} diff --git a/packages/alphatab/src/rendering/EffectInfo.ts b/packages/alphatab/src/rendering/EffectInfo.ts index 94a830077..6b04e942e 100644 --- a/packages/alphatab/src/rendering/EffectInfo.ts +++ b/packages/alphatab/src/rendering/EffectInfo.ts @@ -7,8 +7,26 @@ import type { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGly import type { Settings } from '@coderline/alphatab/Settings'; /** - * A classes inheriting from this base can provide the - * data needed by a EffectBarRenderer to create effect glyphs dynamically. + * Lower = placed first = closer to staff. Gould (Behind Bars p.118, 184, 484). + * @internal + */ +export enum EffectBandPlacementCategory { + /** Articulations, fingerings, dynamics, text, ornaments, fermatas. */ + NoteAttached = 0, + /** Vibrato, let-ring, palm-mute, trill, whammy, hairpins, ottava, pedal, rasgueado, barré. */ + Span = 1, + /** Tempo, rehearsal, section markers, free-time, alternate endings, chords. */ + SystemMarker = 2, + /** + * Single-baseline rows parallel to the stave (Gould p.300). Bands sharing + * {@link EffectInfo.effectId} align at the deepest magnitude across the + * row's combined x-range. + */ + HorizontalRow = 3 +} + +/** + * Provides the data an EffectBarRenderer needs to create effect glyphs. * @internal */ export abstract class EffectInfo { @@ -24,14 +42,6 @@ export abstract class EffectInfo { */ public abstract get notationElement(): NotationElement; - /** - * Gets a value indicating whether this effect can share the space - * with other effects if required. - * (Example: tempo and dynamics don't share their space with other effects, a let-ring and palm-mute will share the space if possible) - * @returns true if this effect bar should only be created once for the first track, otherwise false. - */ - public abstract get canShareBand(): boolean; - /** * Gets a value indicating whether this effect glyphs * should only be added once on the first track if multiple tracks are rendered. @@ -71,6 +81,16 @@ export abstract class EffectInfo { */ public abstract canExpand(from: Beat, to: Beat): boolean; + /** Default {@link EffectBandPlacementCategory.NoteAttached} keeps unknown effects close to the staff. */ + public get placementCategory(): EffectBandPlacementCategory { + return EffectBandPlacementCategory.NoteAttached; + } + + /** When `true`, the band feeds each beat-glyph's paint extent into the rhythmic-spacing solver. */ + public get contributesToBeatSpacing(): boolean { + return false; + } + /** * Override this method to finalize an effect band with all glyphs created. * Allows special layout logic like for whammys where we center-align the glyphs and size the band accordingly. diff --git a/packages/alphatab/src/rendering/EffectSystemPlacement.ts b/packages/alphatab/src/rendering/EffectSystemPlacement.ts new file mode 100644 index 000000000..760fbe70e --- /dev/null +++ b/packages/alphatab/src/rendering/EffectSystemPlacement.ts @@ -0,0 +1,165 @@ +import type { EffectBand, EffectBandXRange } 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'; + +/** + * Priority-ordered skyline oracle that positions every {@link EffectBand} on + * a staff line. Fires from {@link RenderStaff.finalizeStaff}. + * @internal + */ +export class EffectSystemPlacement { + private readonly _staff: RenderStaff; + + // 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 }; + + public constructor(staff: RenderStaff) { + this._staff = staff; + } + + public placeAndApply(): void { + const staff = this._staff; + const sky = staff.systemSkyline; + const pad = staff.system.layout.renderer.settings.display.effectBandPaddingBottom; + + 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. + 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, + // but the band-y loop reads it for every band. + b.placedMagnitude = 0; + b.finalizeBand(); + top.push(b); + } + } + for (const b of r.bottomEffects.bands) { + if (!b.isEmpty) { + b.placedMagnitude = 0; + b.finalizeBand(); + bottom.push(b); + } + } + } + + EffectSystemPlacement._sortByPriority(top); + EffectSystemPlacement._sortByPriority(bottom); + + this._placeSide(top, sky.upSky, pad, /* isTop */ true); + this._placeSide(bottom, sky.downSky, pad, /* isTop */ false); + + 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.registerStaffOverflows(); + } + + const staffTopOverflow = staff.topOverflow; + const staffBottomOverflow = staff.bottomOverflow; + for (const band of top) { + band.y = staffTopOverflow - (band.placedMagnitude + band.height); + } + for (const band of bottom) { + band.y = band.placedMagnitude + band.renderer.bottomEffects.height - staffBottomOverflow; + } + } + + /** 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); + } + + private _placeSide(bands: EffectBand[], sky: Skyline, pad: number, isTop: boolean): void { + const groupBands = this._groupBands; + const groupXStarts = this._groupXStarts; + const groupXEnds = this._groupXEnds; + let i = 0; + while (i < bands.length) { + const band = bands[i]; + + // Group same-magnitude bands: HorizontalRow row mates or linked-chain continuations. + // Two-phase: query all members without inserting (so chain members don't see each other), + // then commit every member at the group's max magnitude. + let groupEnd = i + 1; + const groupEffectId = band.info.effectId; + const groupVoiceIndex = band.voice.index; + if (band.info.placementCategory === EffectBandPlacementCategory.HorizontalRow) { + while ( + groupEnd < bands.length && + bands[groupEnd].info.placementCategory === EffectBandPlacementCategory.HorizontalRow && + bands[groupEnd].info.effectId === groupEffectId && + bands[groupEnd].voice.index === groupVoiceIndex + ) { + groupEnd++; + } + } else { + while ( + groupEnd < bands.length && + bands[groupEnd].info.effectId === groupEffectId && + bands[groupEnd].voice.index === groupVoiceIndex && + bands[groupEnd].isLinkedToPrevious + ) { + groupEnd++; + } + } + + 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)) { + continue; + } + const xStart = m.renderer.x + xRange.xStart; + const xEnd = m.renderer.x + xRange.xEnd; + const mag = isTop + ? sky.placeAbove(xStart, xEnd, m.height, pad) + : sky.placeBelow(xStart, xEnd, m.height, pad); + if (mag > groupMagnitude) { + groupMagnitude = mag; + } + groupBands.push(m); + groupXStarts.push(xStart); + groupXEnds.push(xEnd); + } + for (let k = 0; k < groupBands.length; k++) { + const b = groupBands[k]; + b.placedMagnitude = groupMagnitude; + sky.insert(groupXStarts[k], groupXEnds[k], groupMagnitude + b.height, pad); + } + i = groupEnd; + } + } +} diff --git a/packages/alphatab/src/rendering/LineBarRenderer.ts b/packages/alphatab/src/rendering/LineBarRenderer.ts index 2cf28ab75..2749201e1 100644 --- a/packages/alphatab/src/rendering/LineBarRenderer.ts +++ b/packages/alphatab/src/rendering/LineBarRenderer.ts @@ -18,9 +18,21 @@ import { FlagGlyph } from '@coderline/alphatab/rendering/glyphs/FlagGlyph'; import { RepeatCountGlyph } from '@coderline/alphatab/rendering/glyphs/RepeatCountGlyph'; import { SpacingGlyph } from '@coderline/alphatab/rendering/glyphs/SpacingGlyph'; import { BeamDirection } from '@coderline/alphatab/rendering/utils/BeamDirection'; -import { BeamingHelper, BeamingHelperDrawInfo } from '@coderline/alphatab/rendering/utils/BeamingHelper'; +import { BeamingHelper, type BeamingHelperDrawInfo } from '@coderline/alphatab/rendering/utils/BeamingHelper'; import { ElementStyleHelper } from '@coderline/alphatab/rendering/utils/ElementStyleHelper'; +/** + * Vertical envelope of a {@link BeamingHelper}'s beam/flag/tuplet-bracket + * extent, shared by the scalar overflow pass and the per-x skyline pass. + * + * @record + * @internal + */ +interface BeamingBounds { + topY: number; + bottomY: number; +} + /** * This is a base class for any bar renderer which renders music notation on a staff * with lines like Standard Notation, Guitar Tablatures and Slash Notation. @@ -214,7 +226,7 @@ export abstract class LineBarRenderer extends BarRendererBase { protected calculateBeamYWithDirection(h: BeamingHelper, x: number, direction: BeamDirection): number { this.ensureBeamDrawingInfo(h, direction); - return h.drawingInfos.get(direction)!.calcY(x); + return h.getDrawingInfo(direction).calcY(x); } private _paintTupletHelper( @@ -883,150 +895,130 @@ export abstract class LineBarRenderer extends BarRendererBase { canvas.fill(); } - protected calculateBeamingOverflows(rendererTop: number, rendererBottom: number) { - let maxNoteY = 0; - let minNoteY = 0; - - for (const v of this.helpers.beamHelpers) { - for (const h of v) { - if (!this.shouldPaintBeamingHelper(h)) { - // beam is not drawn, but a rest-only tuplet still draws a bracket - // anchored to the rest glyph bounds and needs overflow reserved. - if (h.hasTuplet && h.isRestBeamHelper) { - const tupletGroup = h.beats[0].tupletGroup!; - const tupletFirst = tupletGroup.beats[0]; - const tupletLast = tupletGroup.beats[tupletGroup.beats.length - 1]; - const tupletDirection = this.getTupletBeamDirection(h); - if (tupletDirection === BeamDirection.Up) { - const restTop = Math.min( - this.getRestY(tupletFirst, NoteYPosition.Top), - this.getRestY(tupletLast, NoteYPosition.Top) - ); - const topY = restTop - this.tupletSize - this.tupletOffset; - if (topY < maxNoteY) { - maxNoteY = topY; - } - } else { - const restBottom = Math.max( - this.getRestY(tupletFirst, NoteYPosition.Bottom), - this.getRestY(tupletLast, NoteYPosition.Bottom) - ); - const bottomY = restBottom + this.tupletSize + this.tupletOffset; - if (bottomY > minNoteY) { - minNoteY = bottomY; - } - } - } + /** Writes the helper's beam/flag/tuplet-bracket y-extent into `out` (0 = no overflow on that side). */ + private _computeBeamingBounds(h: BeamingHelper, out: BeamingBounds): void { + let topY = 0; + let bottomY = 0; + if (!this.shouldPaintBeamingHelper(h)) { + // Beam isn't drawn, but a rest-only tuplet still draws a bracket. + if (h.hasTuplet && h.isRestBeamHelper) { + const tupletGroup = h.beats[0].tupletGroup!; + const tupletFirst = tupletGroup.beats[0]; + const tupletLast = tupletGroup.beats[tupletGroup.beats.length - 1]; + const tupletDirection = this.getTupletBeamDirection(h); + if (tupletDirection === BeamDirection.Up) { + const restTop = Math.min( + this.getRestY(tupletFirst, NoteYPosition.Top), + this.getRestY(tupletLast, NoteYPosition.Top) + ); + topY = restTop - this.tupletSize - this.tupletOffset; + } else { + const restBottom = Math.max( + this.getRestY(tupletFirst, NoteYPosition.Bottom), + this.getRestY(tupletLast, NoteYPosition.Bottom) + ); + bottomY = restBottom + this.tupletSize + this.tupletOffset; } - // notes with stems (and potential flags) - else if (h.beats.length === 1 && h.beats[0].duration >= Duration.Half) { - const tupletDirection = this.getTupletBeamDirection(h); - const direction = this.getBeamDirection(h); - const flagOverflow = this.smuflMetrics.stemFlagOffsets.get(h.beats[0].duration)!; - if (direction === BeamDirection.Up) { - let topY = this.getFlagTopY(h.beats[0], direction) - flagOverflow; - if (h.hasTuplet && tupletDirection === direction) { - topY -= this.tupletSize + this.tupletOffset; - } - if (topY < maxNoteY) { - maxNoteY = topY; - } - - if (h.hasTuplet && tupletDirection !== direction) { - let bottomY = this.getFlagBottomY(h.beats[0], tupletDirection); - bottomY += this.tupletSize + this.tupletOffset; - - if (bottomY > minNoteY) { - minNoteY = bottomY; - } - } - - // bottom handled via beat container bBox - } else { - let bottomY = this.getFlagBottomY(h.beats[0], direction) + flagOverflow; - if (h.hasTuplet && tupletDirection === direction) { - bottomY += this.tupletSize + this.tupletOffset; - } - if (bottomY > minNoteY) { - minNoteY = bottomY; - } - - if (h.hasTuplet && tupletDirection !== direction) { - let topY = this.getFlagTopY(h.beats[0], tupletDirection); - topY -= this.tupletSize + this.tupletOffset; - - if (topY < maxNoteY) { - maxNoteY = topY; - } - } - - // top handled via beat container bBox - } + } + } else if (h.beats.length === 1 && h.beats[0].duration >= Duration.Half) { + const tupletDirection = this.getTupletBeamDirection(h); + const direction = this.getBeamDirection(h); + const flagOverflow = this.smuflMetrics.stemFlagOffsets.get(h.beats[0].duration)!; + if (direction === BeamDirection.Up) { + topY = this.getFlagTopY(h.beats[0], direction) - flagOverflow; + if (h.hasTuplet && tupletDirection === direction) { + topY -= this.tupletSize + this.tupletOffset; } - // beamed notes and notes without stems - // (see paintTuplets in case of doubts how we handle tuplets on non beamed notes) - else { - const direction = this.getBeamDirection(h); - this.ensureBeamDrawingInfo(h, direction); - const drawingInfo = h.drawingInfos.get(direction)!; - const tupletDirection = this.getTupletBeamDirection(h); - - if (direction === BeamDirection.Up) { - let topY = Math.min(drawingInfo.startY, drawingInfo.endY); - if (h.hasTuplet && tupletDirection === direction) { - topY -= this.tupletSize + this.tupletOffset; - } - - if (topY < maxNoteY) { - maxNoteY = topY; - } - - let bottomY: number = this.voiceContainer.getLowestNoteY( - h.beatOfLowestNote, - NoteYPosition.Bottom - ); - if (h.hasTuplet && tupletDirection !== direction) { - bottomY += this.tupletSize + this.tupletOffset; - } - - if (bottomY > minNoteY) { - minNoteY = bottomY; - } - } else { - let bottomY = Math.max(drawingInfo.startY, drawingInfo.endY); - - if (h.hasTuplet && tupletDirection === direction) { - bottomY += this.tupletSize + this.tupletOffset; - } - - if (bottomY > minNoteY) { - minNoteY = bottomY; - } + if (h.hasTuplet && tupletDirection !== direction) { + bottomY = this.getFlagBottomY(h.beats[0], tupletDirection); + bottomY += this.tupletSize + this.tupletOffset; + } + // bottom handled via beat container bBox + } else { + bottomY = this.getFlagBottomY(h.beats[0], direction) + flagOverflow; + if (h.hasTuplet && tupletDirection === direction) { + bottomY += this.tupletSize + this.tupletOffset; + } + if (h.hasTuplet && tupletDirection !== direction) { + topY = this.getFlagTopY(h.beats[0], tupletDirection); + topY -= this.tupletSize + this.tupletOffset; + } + // top handled via beat container bBox + } + } else { + const direction = this.getBeamDirection(h); + this.ensureBeamDrawingInfo(h, direction); + const drawingInfo = h.getDrawingInfo(direction); + const tupletDirection = this.getTupletBeamDirection(h); + if (direction === BeamDirection.Up) { + topY = Math.min(drawingInfo.startY, drawingInfo.endY); + if (h.hasTuplet && tupletDirection === direction) { + topY -= this.tupletSize + this.tupletOffset; + } + if (h.hasTuplet && tupletDirection !== direction) { + // Use flag position (matches paintTuplets); getLowestNoteY skips content below the notehead. + bottomY = + this.getFlagBottomY(h.beatOfLowestNote, tupletDirection) + this.tupletSize + this.tupletOffset; + } else { + bottomY = this.voiceContainer.getLowestNoteY(h.beatOfLowestNote, NoteYPosition.Bottom); + } + } else { + bottomY = Math.max(drawingInfo.startY, drawingInfo.endY); + if (h.hasTuplet && tupletDirection === direction) { + bottomY += this.tupletSize + this.tupletOffset; + } + if (h.hasTuplet && tupletDirection !== direction) { + // Use flag position (matches paintTuplets); getHighestNoteY skips octave dots. + topY = this.getFlagTopY(h.beatOfHighestNote, tupletDirection) - this.tupletSize - this.tupletOffset; + } else { + topY = this.voiceContainer.getHighestNoteY(h.beatOfHighestNote, NoteYPosition.Top); + } + } + } + out.topY = topY; + out.bottomY = bottomY; + } - let topY: number = this.voiceContainer.getHighestNoteY(h.beatOfHighestNote, NoteYPosition.Top); - if (h.hasTuplet && tupletDirection !== direction) { - topY -= this.tupletSize + this.tupletOffset; - } + private readonly _beamingBoundsScratch: BeamingBounds = { topY: 0, bottomY: 0 }; - if (topY < maxNoteY) { - maxNoteY = topY; - } - } + protected calculateBeamingOverflows(rendererTop: number, rendererBottom: number) { + const out = this._beamingBoundsScratch; + for (const v of this.helpers.beamHelpers) { + for (const h of v) { + this._computeBeamingBounds(h, out); + if (out.topY < rendererTop) { + this.registerOverflowTop(Math.abs(out.topY)); + } + if (out.bottomY > rendererBottom) { + this.registerOverflowBottom(Math.abs(out.bottomY) - rendererBottom); } } } + } - if (maxNoteY < rendererTop) { - this.registerOverflowTop(Math.abs(maxNoteY)); + protected override emitHelperSkyline(h: BeamingHelper): void { + const rendererBottom = this.height; + const out = this._beamingBoundsScratch; + this._computeBeamingBounds(h, out); + if (out.topY >= 0 && out.bottomY <= rendererBottom) { + return; } - - if (minNoteY > rendererBottom) { - this.registerOverflowBottom(Math.abs(minNoteY) - rendererBottom); + const firstBeat = h.beats[0]; + const lastBeat = h.beats[h.beats.length - 1]; + const xStart = this.getBeatX(firstBeat, BeatXPosition.PreNotes); + const xEnd = this.getBeatX(lastBeat, BeatXPosition.PostNotes); + if (out.topY < 0) { + this.insertSkylineTop(xStart, xEnd, Math.abs(out.topY)); + } + if (out.bottomY > rendererBottom) { + this.insertSkylineBottom(xStart, xEnd, Math.abs(out.bottomY) - rendererBottom); } } protected initializeBeamDrawingInfo(h: BeamingHelper, direction: BeamDirection) { - const drawingInfo = new BeamingHelperDrawInfo(); + // Populate the helper's pre-allocated slot in place; caller marks it + // valid once mid-element shifts complete. + const drawingInfo = h.getDrawingInfo(direction); const firstBeat = h.beats[0]; const lastBeat = h.beats[h.beats.length - 1]; @@ -1090,7 +1082,7 @@ export abstract class LineBarRenderer extends BarRendererBase { } protected ensureBeamDrawingInfo(h: BeamingHelper, direction: BeamDirection): void { - if (h.drawingInfos.has(direction)) { + if (h.hasDrawingInfo(direction)) { return; } @@ -1101,7 +1093,7 @@ export abstract class LineBarRenderer extends BarRendererBase { // 3. any middle elements (notes or rests) shift this diagonal line up/down to avoid overlaps const drawingInfo = this.initializeBeamDrawingInfo(h, direction); - h.drawingInfos.set(direction, drawingInfo); + h.markDrawingInfoValid(direction); const barCount: number = ModelUtils.getIndex(h.shortestDuration) - 2; diff --git a/packages/alphatab/src/rendering/NumberedBarRenderer.ts b/packages/alphatab/src/rendering/NumberedBarRenderer.ts index 1e6a29a6c..1a6f1045c 100644 --- a/packages/alphatab/src/rendering/NumberedBarRenderer.ts +++ b/packages/alphatab/src/rendering/NumberedBarRenderer.ts @@ -390,7 +390,7 @@ export class NumberedBarRenderer extends LineBarRenderer { protected override calculateBeamYWithDirection(h: BeamingHelper, _x: number, direction: BeamDirection): number { this.ensureBeamDrawingInfo(h, direction); - const info = h.drawingInfos.get(direction)!; + const info = h.getDrawingInfo(direction); if (direction === BeamDirection.Up) { return Math.min(info.startY, info.endY); } else { diff --git a/packages/alphatab/src/rendering/ScoreBarRenderer.ts b/packages/alphatab/src/rendering/ScoreBarRenderer.ts index c5f09c158..4fd40f847 100644 --- a/packages/alphatab/src/rendering/ScoreBarRenderer.ts +++ b/packages/alphatab/src/rendering/ScoreBarRenderer.ts @@ -148,10 +148,9 @@ export class ScoreBarRenderer extends LineBarRenderer { return this.getScoreY(this.bar.staff.standardNotationLineCount - 1); } - public override applyLayoutingInfo(): boolean { - const result = super.applyLayoutingInfo(); - if (result && this.bar.isMultiVoice) { - // consider rest overflows + public override applyLayoutingInfo(): void { + super.applyLayoutingInfo(); + if (this.bar.isMultiVoice) { const top: number = this.getScoreY(-2); const bottom: number = this.getScoreY(this.heightLineCount * 2); const minMax = this.helpers.collisionHelper.getBeatMinMaxY(); @@ -162,7 +161,6 @@ export class ScoreBarRenderer extends LineBarRenderer { this.registerOverflowBottom(Math.abs(minMax[1]) - bottom); } } - return result; } protected override getMinLineOfBeat(beat: Beat): number { diff --git a/packages/alphatab/src/rendering/SlashBarRenderer.ts b/packages/alphatab/src/rendering/SlashBarRenderer.ts index b6165c1ab..59e352e2e 100644 --- a/packages/alphatab/src/rendering/SlashBarRenderer.ts +++ b/packages/alphatab/src/rendering/SlashBarRenderer.ts @@ -5,6 +5,7 @@ import type { Voice } from '@coderline/alphatab/model/Voice'; import type { ICanvas } from '@coderline/alphatab/platform/ICanvas'; import { LineBarRenderer } from '@coderline/alphatab/rendering//LineBarRenderer'; import { NoteYPosition } from '@coderline/alphatab/rendering/BarRendererBase'; +import { BeatXPosition } from '@coderline/alphatab/rendering/BeatXPosition'; import { ScoreTimeSignatureGlyph } from '@coderline/alphatab/rendering/glyphs/ScoreTimeSignatureGlyph'; import { SpacingGlyph } from '@coderline/alphatab/rendering/glyphs/SpacingGlyph'; import type { ScoreRenderer } from '@coderline/alphatab/rendering/ScoreRenderer'; @@ -81,6 +82,21 @@ export class SlashBarRenderer extends LineBarRenderer { } } + protected override emitHelperSkyline(h: BeamingHelper): void { + super.emitHelperSkyline(h); + if (h.hasTuplet) { + // Tuplets can span multiple helpers — emit once per group, from its first beat. + const group = h.beats[0].tupletGroup!; + if (group.beats.length > 0 && group.beats[0] === h.beats[0]) { + const xStart = this.getBeatX(group.beats[0], BeatXPosition.PreNotes); + const xEnd = this.getBeatX(group.beats[group.beats.length - 1], BeatXPosition.PostNotes); + if (xEnd > xStart) { + this.insertSkylineTop(xStart, xEnd, this.tupletSize); + } + } + } + } + public getNoteLine(_note: Note) { return 0; } diff --git a/packages/alphatab/src/rendering/TabBarRenderer.ts b/packages/alphatab/src/rendering/TabBarRenderer.ts index 122975265..a08f30a37 100644 --- a/packages/alphatab/src/rendering/TabBarRenderer.ts +++ b/packages/alphatab/src/rendering/TabBarRenderer.ts @@ -6,6 +6,11 @@ import type { Voice } from '@coderline/alphatab/model/Voice'; import { TabRhythmMode } from '@coderline/alphatab/NotationSettings'; import type { ICanvas } from '@coderline/alphatab/platform/ICanvas'; import { NoteYPosition } from '@coderline/alphatab/rendering/BarRendererBase'; +import { BeatXPosition } from '@coderline/alphatab/rendering/BeatXPosition'; +import { + BeatContainerGlyph, + type BeatContainerGlyphBase +} from '@coderline/alphatab/rendering/glyphs/BeatContainerGlyph'; import { SpacingGlyph } from '@coderline/alphatab/rendering/glyphs/SpacingGlyph'; import { TabBeatContainerGlyph } from '@coderline/alphatab/rendering/glyphs/TabBeatContainerGlyph'; import type { TabBeatGlyph } from '@coderline/alphatab/rendering/glyphs/TabBeatGlyph'; @@ -138,6 +143,33 @@ export class TabBarRenderer extends LineBarRenderer { } } + public override emitBeatSkyline(beatContainer: BeatContainerGlyphBase): void { + if (!(beatContainer instanceof BeatContainerGlyph)) { + return; + } + // Per-beat half-line overflow, not bar-wide. Strings are 1-indexed: top = tuning.length, bottom = 1. + const beat = beatContainer.beat; + const stringCount = this.bar.staff.tuning.length; + const hasTop = beat.maxStringNote !== null && beat.maxStringNote.string === stringCount; + const hasBottom = beat.minStringNote !== null && beat.minStringNote.string === 1; + if (!hasTop && !hasBottom) { + return; + } + const base = this.voiceContainer.x + beatContainer.x; + const xStart = base + beatContainer.getBeatX(BeatXPosition.PreNotes, false); + const xEnd = base + beatContainer.getBeatX(BeatXPosition.PostNotes, false); + if (xEnd <= xStart) { + return; + } + const halfLine = this.lineSpacing / 2; + if (hasTop) { + this.insertSkylineTop(xStart, xEnd, halfLine); + } + if (hasBottom) { + this.insertSkylineBottom(xStart, xEnd, halfLine); + } + } + protected override createLinePreBeatGlyphs(): void { // Clef if (this.isFirstOfStaff) { @@ -321,4 +353,23 @@ export class TabBarRenderer extends LineBarRenderer { this.calculateBeamingOverflows(rendererTop, rendererBottom); } } + + protected override emitHelperSkyline(h: BeamingHelper): void { + if (this.rhythmMode === TabRhythmMode.Hidden) { + return; + } + super.emitHelperSkyline(h); + if (h.hasTuplet) { + // Tuplets can span multiple helpers — emit once per group, from its first beat. + const group = h.beats[0].tupletGroup!; + if (group.beats.length > 0 && group.beats[0] === h.beats[0]) { + const tupletHeight = this.settings.notation.rhythmHeight + this.tupletSize; + const xStart = this.getBeatX(group.beats[0], BeatXPosition.PreNotes); + const xEnd = this.getBeatX(group.beats[group.beats.length - 1], BeatXPosition.PostNotes); + if (xEnd > xStart) { + this.insertSkylineBottom(xStart, xEnd, tupletHeight); + } + } + } + } } diff --git a/packages/alphatab/src/rendering/effects/AlternateEndingsEffectInfo.ts b/packages/alphatab/src/rendering/effects/AlternateEndingsEffectInfo.ts index ecab2d4e0..6acf07027 100644 --- a/packages/alphatab/src/rendering/effects/AlternateEndingsEffectInfo.ts +++ b/packages/alphatab/src/rendering/effects/AlternateEndingsEffectInfo.ts @@ -1,11 +1,11 @@ import type { Beat } from '@coderline/alphatab/model/Beat'; +import { NotationElement } from '@coderline/alphatab/NotationSettings'; import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; import { EffectBarGlyphSizing } from '@coderline/alphatab/rendering/EffectBarGlyphSizing'; +import { EffectBandPlacementCategory, EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import { AlternateEndingsGlyph } from '@coderline/alphatab/rendering/glyphs/AlternateEndingsGlyph'; import type { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; -import { EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import type { Settings } from '@coderline/alphatab/Settings'; -import { NotationElement } from '@coderline/alphatab/NotationSettings'; /** * @internal @@ -19,10 +19,6 @@ export class AlternateEndingsEffectInfo extends EffectInfo { return true; } - public get canShareBand(): boolean { - return false; - } - public get sizingMode(): EffectBarGlyphSizing { return EffectBarGlyphSizing.FullBar; } @@ -56,4 +52,8 @@ export class AlternateEndingsEffectInfo extends EffectInfo { public canExpand(_from: Beat, _to: Beat): boolean { return true; } + public override get placementCategory(): EffectBandPlacementCategory { + // Voltas share one baseline across the system (Gould Ch.11). + return EffectBandPlacementCategory.HorizontalRow; + } } diff --git a/packages/alphatab/src/rendering/effects/BeatBarreEffectInfo.ts b/packages/alphatab/src/rendering/effects/BeatBarreEffectInfo.ts index b17984aae..f17cd6c6f 100644 --- a/packages/alphatab/src/rendering/effects/BeatBarreEffectInfo.ts +++ b/packages/alphatab/src/rendering/effects/BeatBarreEffectInfo.ts @@ -1,12 +1,12 @@ +import { BarreShape } from '@coderline/alphatab/model/BarreShape'; import type { Beat } from '@coderline/alphatab/model/Beat'; +import { NotationElement } from '@coderline/alphatab/NotationSettings'; import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; import { EffectBarGlyphSizing } from '@coderline/alphatab/rendering/EffectBarGlyphSizing'; +import { EffectBandPlacementCategory, EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import type { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; import { LineRangedGlyph } from '@coderline/alphatab/rendering/glyphs/LineRangedGlyph'; -import { EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import type { Settings } from '@coderline/alphatab/Settings'; -import { NotationElement } from '@coderline/alphatab/NotationSettings'; -import { BarreShape } from '@coderline/alphatab/model/BarreShape'; /** * @internal @@ -16,10 +16,6 @@ export class BeatBarreEffectInfo extends EffectInfo { return NotationElement.EffectLetRing; } - public get canShareBand(): boolean { - return false; - } - public get hideOnMultiTrack(): boolean { return false; } @@ -81,4 +77,7 @@ export class BeatBarreEffectInfo extends EffectInfo { public canExpand(from: Beat, to: Beat): boolean { return from.barreFret === to.barreFret && from.barreShape === to.barreShape; } + public override get placementCategory(): EffectBandPlacementCategory { + return EffectBandPlacementCategory.Span; + } } diff --git a/packages/alphatab/src/rendering/effects/BeatTimerEffectInfo.ts b/packages/alphatab/src/rendering/effects/BeatTimerEffectInfo.ts index ec8b5a8c4..9c34b6a98 100644 --- a/packages/alphatab/src/rendering/effects/BeatTimerEffectInfo.ts +++ b/packages/alphatab/src/rendering/effects/BeatTimerEffectInfo.ts @@ -1,11 +1,11 @@ -import { NotationElement } from '@coderline/alphatab/NotationSettings'; -import { EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; -import { EffectBarGlyphSizing } from '@coderline/alphatab/rendering/EffectBarGlyphSizing'; -import type { Settings } from '@coderline/alphatab/Settings'; import type { Beat } from '@coderline/alphatab/model/Beat'; +import { NotationElement } from '@coderline/alphatab/NotationSettings'; import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; -import type { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; +import { EffectBarGlyphSizing } from '@coderline/alphatab/rendering/EffectBarGlyphSizing'; +import { EffectBandPlacementCategory, EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import { BeatTimerGlyph } from '@coderline/alphatab/rendering/glyphs/BeatTimerGlyph'; +import type { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; +import type { Settings } from '@coderline/alphatab/Settings'; /** * @internal @@ -19,10 +19,6 @@ export class BeatTimerEffectInfo extends EffectInfo { return true; } - public get canShareBand(): boolean { - return true; - } - public get sizingMode(): EffectBarGlyphSizing { return EffectBarGlyphSizing.SingleOnBeat; } @@ -38,4 +34,7 @@ export class BeatTimerEffectInfo extends EffectInfo { public canExpand(_from: Beat, _to: Beat): boolean { return true; } + public override get placementCategory(): EffectBandPlacementCategory { + return EffectBandPlacementCategory.SystemMarker; + } } diff --git a/packages/alphatab/src/rendering/effects/CapoEffectInfo.ts b/packages/alphatab/src/rendering/effects/CapoEffectInfo.ts index 65c8eb2a2..07d4b1bee 100644 --- a/packages/alphatab/src/rendering/effects/CapoEffectInfo.ts +++ b/packages/alphatab/src/rendering/effects/CapoEffectInfo.ts @@ -1,12 +1,12 @@ import type { Beat } from '@coderline/alphatab/model/Beat'; +import { NotationElement } from '@coderline/alphatab/NotationSettings'; import { TextAlign } from '@coderline/alphatab/platform/ICanvas'; import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; import { EffectBarGlyphSizing } from '@coderline/alphatab/rendering/EffectBarGlyphSizing'; +import { EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import type { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; import { TextGlyph } from '@coderline/alphatab/rendering/glyphs/TextGlyph'; -import { EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import type { Settings } from '@coderline/alphatab/Settings'; -import { NotationElement } from '@coderline/alphatab/NotationSettings'; /** * @internal @@ -20,10 +20,6 @@ export class CapoEffectInfo extends EffectInfo { return false; } - public get canShareBand(): boolean { - return false; - } - public get sizingMode(): EffectBarGlyphSizing { return EffectBarGlyphSizing.SingleOnBeat; } diff --git a/packages/alphatab/src/rendering/effects/ChordsEffectInfo.ts b/packages/alphatab/src/rendering/effects/ChordsEffectInfo.ts index f5eafef22..95b7bd905 100644 --- a/packages/alphatab/src/rendering/effects/ChordsEffectInfo.ts +++ b/packages/alphatab/src/rendering/effects/ChordsEffectInfo.ts @@ -1,13 +1,13 @@ import type { Beat } from '@coderline/alphatab/model/Beat'; +import { NotationElement } from '@coderline/alphatab/NotationSettings'; import { TextAlign } from '@coderline/alphatab/platform/ICanvas'; import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; import { EffectBarGlyphSizing } from '@coderline/alphatab/rendering/EffectBarGlyphSizing'; +import { EffectBandPlacementCategory, EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; +import { ChordDiagramGlyph } from '@coderline/alphatab/rendering/glyphs/ChordDiagramGlyph'; import type { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; import { TextGlyph } from '@coderline/alphatab/rendering/glyphs/TextGlyph'; -import { EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import type { Settings } from '@coderline/alphatab/Settings'; -import { NotationElement } from '@coderline/alphatab/NotationSettings'; -import { ChordDiagramGlyph } from '@coderline/alphatab/rendering/glyphs/ChordDiagramGlyph'; /** * @internal @@ -21,10 +21,6 @@ export class ChordsEffectInfo extends EffectInfo { return false; } - public get canShareBand(): boolean { - return true; - } - public get sizingMode(): EffectBarGlyphSizing { return EffectBarGlyphSizing.SingleOnBeat; } @@ -49,4 +45,7 @@ export class ChordsEffectInfo extends EffectInfo { public canExpand(_from: Beat, _to: Beat): boolean { return false; } + public override get placementCategory(): EffectBandPlacementCategory { + return EffectBandPlacementCategory.SystemMarker; + } } diff --git a/packages/alphatab/src/rendering/effects/CrescendoEffectInfo.ts b/packages/alphatab/src/rendering/effects/CrescendoEffectInfo.ts index 5127cfa5a..0bb3348cc 100644 --- a/packages/alphatab/src/rendering/effects/CrescendoEffectInfo.ts +++ b/packages/alphatab/src/rendering/effects/CrescendoEffectInfo.ts @@ -1,12 +1,12 @@ import type { Beat } from '@coderline/alphatab/model/Beat'; import { CrescendoType } from '@coderline/alphatab/model/CrescendoType'; +import { NotationElement } from '@coderline/alphatab/NotationSettings'; import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; import { EffectBarGlyphSizing } from '@coderline/alphatab/rendering/EffectBarGlyphSizing'; +import { EffectBandPlacementCategory, EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import { CrescendoGlyph } from '@coderline/alphatab/rendering/glyphs/CrescendoGlyph'; import type { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; -import { EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import type { Settings } from '@coderline/alphatab/Settings'; -import { NotationElement } from '@coderline/alphatab/NotationSettings'; /** * @internal @@ -20,10 +20,6 @@ export class CrescendoEffectInfo extends EffectInfo { return false; } - public get canShareBand(): boolean { - return true; - } - public get sizingMode(): EffectBarGlyphSizing { return EffectBarGlyphSizing.GroupedOnBeatToEnd; } @@ -39,4 +35,7 @@ export class CrescendoEffectInfo extends EffectInfo { public canExpand(from: Beat, to: Beat): boolean { return from.crescendo === to.crescendo; } + public override get placementCategory(): EffectBandPlacementCategory { + return EffectBandPlacementCategory.Span; + } } diff --git a/packages/alphatab/src/rendering/effects/DirectionsEffectInfo.ts b/packages/alphatab/src/rendering/effects/DirectionsEffectInfo.ts index 8e9f6e1cb..c24836cef 100644 --- a/packages/alphatab/src/rendering/effects/DirectionsEffectInfo.ts +++ b/packages/alphatab/src/rendering/effects/DirectionsEffectInfo.ts @@ -1,11 +1,11 @@ import type { Beat } from '@coderline/alphatab/model/Beat'; +import { NotationElement } from '@coderline/alphatab/NotationSettings'; import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; import { EffectBarGlyphSizing } from '@coderline/alphatab/rendering/EffectBarGlyphSizing'; +import { EffectBandPlacementCategory, EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; +import { DirectionsContainerGlyph } from '@coderline/alphatab/rendering/glyphs/DirectionsContainerGlyph'; import type { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; -import { EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import type { Settings } from '@coderline/alphatab/Settings'; -import { NotationElement } from '@coderline/alphatab/NotationSettings'; -import { DirectionsContainerGlyph } from '@coderline/alphatab/rendering/glyphs/DirectionsContainerGlyph'; /** * @internal @@ -19,10 +19,6 @@ export class DirectionsEffectInfo extends EffectInfo { return true; } - public get canShareBand(): boolean { - return false; - } - public get sizingMode(): EffectBarGlyphSizing { return EffectBarGlyphSizing.FullBar; } @@ -41,6 +37,10 @@ export class DirectionsEffectInfo extends EffectInfo { } public canExpand(_from: Beat, _to: Beat): boolean { - return true; + // Each bar's directions are independent — no cross-bar chain to share a y. + return false; + } + public override get placementCategory(): EffectBandPlacementCategory { + return EffectBandPlacementCategory.SystemMarker; } } diff --git a/packages/alphatab/src/rendering/effects/DynamicsEffectInfo.ts b/packages/alphatab/src/rendering/effects/DynamicsEffectInfo.ts index 11b100781..45934bfc9 100644 --- a/packages/alphatab/src/rendering/effects/DynamicsEffectInfo.ts +++ b/packages/alphatab/src/rendering/effects/DynamicsEffectInfo.ts @@ -1,11 +1,11 @@ import type { Beat } from '@coderline/alphatab/model/Beat'; +import { NotationElement } from '@coderline/alphatab/NotationSettings'; import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; import { EffectBarGlyphSizing } from '@coderline/alphatab/rendering/EffectBarGlyphSizing'; +import { EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import { DynamicsGlyph } from '@coderline/alphatab/rendering/glyphs/DynamicsGlyph'; import type { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; -import { EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import type { Settings } from '@coderline/alphatab/Settings'; -import { NotationElement } from '@coderline/alphatab/NotationSettings'; /** * @internal @@ -19,10 +19,6 @@ export class DynamicsEffectInfo extends EffectInfo { return false; } - public get canShareBand(): boolean { - return false; - } - public get sizingMode(): EffectBarGlyphSizing { return EffectBarGlyphSizing.SingleOnBeat; } diff --git a/packages/alphatab/src/rendering/effects/FadeEffectInfo.ts b/packages/alphatab/src/rendering/effects/FadeEffectInfo.ts index 5a73dca74..bb5733504 100644 --- a/packages/alphatab/src/rendering/effects/FadeEffectInfo.ts +++ b/packages/alphatab/src/rendering/effects/FadeEffectInfo.ts @@ -1,12 +1,12 @@ import type { Beat } from '@coderline/alphatab/model/Beat'; +import { FadeType } from '@coderline/alphatab/model/FadeType'; +import { NotationElement } from '@coderline/alphatab/NotationSettings'; import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; import { EffectBarGlyphSizing } from '@coderline/alphatab/rendering/EffectBarGlyphSizing'; +import { EffectBandPlacementCategory, EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import type { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; -import { EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; -import type { Settings } from '@coderline/alphatab/Settings'; -import { NotationElement } from '@coderline/alphatab/NotationSettings'; -import { FadeType } from '@coderline/alphatab/model/FadeType'; import { FadeGlyph } from '@coderline/alphatab/rendering/glyphs/FadeGlyph'; +import type { Settings } from '@coderline/alphatab/Settings'; /** * @internal @@ -20,10 +20,6 @@ export class FadeEffectInfo extends EffectInfo { return false; } - public get canShareBand(): boolean { - return true; - } - public get sizingMode(): EffectBarGlyphSizing { return EffectBarGlyphSizing.SingleOnBeat; } @@ -39,4 +35,7 @@ export class FadeEffectInfo extends EffectInfo { public canExpand(_from: Beat, _to: Beat): boolean { return true; } + public override get placementCategory(): EffectBandPlacementCategory { + return EffectBandPlacementCategory.Span; + } } diff --git a/packages/alphatab/src/rendering/effects/FermataEffectInfo.ts b/packages/alphatab/src/rendering/effects/FermataEffectInfo.ts index d4b621b51..557d19206 100644 --- a/packages/alphatab/src/rendering/effects/FermataEffectInfo.ts +++ b/packages/alphatab/src/rendering/effects/FermataEffectInfo.ts @@ -1,11 +1,11 @@ import type { Beat } from '@coderline/alphatab/model/Beat'; +import { NotationElement } from '@coderline/alphatab/NotationSettings'; import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; import { EffectBarGlyphSizing } from '@coderline/alphatab/rendering/EffectBarGlyphSizing'; +import { EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import type { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; import { FermataGlyph } from '@coderline/alphatab/rendering/glyphs/FermataGlyph'; -import { EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import type { Settings } from '@coderline/alphatab/Settings'; -import { NotationElement } from '@coderline/alphatab/NotationSettings'; /** * @internal @@ -19,10 +19,6 @@ export class FermataEffectInfo extends EffectInfo { return false; } - public get canShareBand(): boolean { - return false; - } - public get sizingMode(): EffectBarGlyphSizing { return EffectBarGlyphSizing.SingleOnBeat; } @@ -38,4 +34,9 @@ export class FermataEffectInfo extends EffectInfo { public canExpand(_from: Beat, _to: Beat): boolean { return true; } + + /** Centered around onTimeX; needs its half-width reserved in the beat spring. */ + public override get contributesToBeatSpacing(): boolean { + return true; + } } diff --git a/packages/alphatab/src/rendering/effects/FingeringEffectInfo.ts b/packages/alphatab/src/rendering/effects/FingeringEffectInfo.ts index f6c13ee3e..d9b1583b5 100644 --- a/packages/alphatab/src/rendering/effects/FingeringEffectInfo.ts +++ b/packages/alphatab/src/rendering/effects/FingeringEffectInfo.ts @@ -4,11 +4,11 @@ import type { Note } from '@coderline/alphatab/model/Note'; import { FingeringMode, NotationElement } from '@coderline/alphatab/NotationSettings'; import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; import { EffectBarGlyphSizing } from '@coderline/alphatab/rendering/EffectBarGlyphSizing'; -import type { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; import { EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; -import type { Settings } from '@coderline/alphatab/Settings'; +import type { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; import { FingeringGroupGlyph } from '@coderline/alphatab/rendering/glyphs/FingeringGroupGlyph'; import { MusicFontGlyph } from '@coderline/alphatab/rendering/glyphs/MusicFontGlyph'; +import type { Settings } from '@coderline/alphatab/Settings'; /** * @internal @@ -22,10 +22,6 @@ export class FingeringEffectInfo extends EffectInfo { return false; } - public get canShareBand(): boolean { - return true; - } - public get sizingMode(): EffectBarGlyphSizing { return EffectBarGlyphSizing.SingleOnBeat; } diff --git a/packages/alphatab/src/rendering/effects/FreeTimeEffectInfo.ts b/packages/alphatab/src/rendering/effects/FreeTimeEffectInfo.ts index 848b69c03..c20e9ebeb 100644 --- a/packages/alphatab/src/rendering/effects/FreeTimeEffectInfo.ts +++ b/packages/alphatab/src/rendering/effects/FreeTimeEffectInfo.ts @@ -1,12 +1,12 @@ import type { Beat } from '@coderline/alphatab/model/Beat'; +import { NotationElement } from '@coderline/alphatab/NotationSettings'; import { TextAlign } from '@coderline/alphatab/platform/ICanvas'; import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; import { EffectBarGlyphSizing } from '@coderline/alphatab/rendering/EffectBarGlyphSizing'; +import { EffectBandPlacementCategory, EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import type { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; import { TextGlyph } from '@coderline/alphatab/rendering/glyphs/TextGlyph'; -import { EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import type { Settings } from '@coderline/alphatab/Settings'; -import { NotationElement } from '@coderline/alphatab/NotationSettings'; /** * @internal @@ -20,10 +20,6 @@ export class FreeTimeEffectInfo extends EffectInfo { return false; } - public get canShareBand(): boolean { - return true; - } - public get sizingMode(): EffectBarGlyphSizing { return EffectBarGlyphSizing.SinglePreBeat; } @@ -51,4 +47,7 @@ export class FreeTimeEffectInfo extends EffectInfo { public canExpand(_from: Beat, _to: Beat): boolean { return true; } + public override get placementCategory(): EffectBandPlacementCategory { + return EffectBandPlacementCategory.SystemMarker; + } } diff --git a/packages/alphatab/src/rendering/effects/GolpeEffectInfo.ts b/packages/alphatab/src/rendering/effects/GolpeEffectInfo.ts index 3c86a5394..bcf98aa84 100644 --- a/packages/alphatab/src/rendering/effects/GolpeEffectInfo.ts +++ b/packages/alphatab/src/rendering/effects/GolpeEffectInfo.ts @@ -1,12 +1,12 @@ -import { NotationElement } from '@coderline/alphatab/NotationSettings'; -import { EffectBarGlyphSizing } from '@coderline/alphatab/rendering/EffectBarGlyphSizing'; -import type { Settings } from '@coderline/alphatab/Settings'; import type { Beat } from '@coderline/alphatab/model/Beat'; import { GolpeType } from '@coderline/alphatab/model/GolpeType'; -import { EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; +import { NotationElement } from '@coderline/alphatab/NotationSettings'; import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; +import { EffectBarGlyphSizing } from '@coderline/alphatab/rendering/EffectBarGlyphSizing'; +import { EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import type { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; import { GuitarGolpeGlyph } from '@coderline/alphatab/rendering/glyphs/GuitarGolpeGlyph'; +import type { Settings } from '@coderline/alphatab/Settings'; /** * @internal @@ -31,10 +31,6 @@ export class GolpeEffectInfo extends EffectInfo { return false; } - public get canShareBand(): boolean { - return true; - } - public get sizingMode(): EffectBarGlyphSizing { return EffectBarGlyphSizing.SingleOnBeat; } diff --git a/packages/alphatab/src/rendering/effects/LetRingEffectInfo.ts b/packages/alphatab/src/rendering/effects/LetRingEffectInfo.ts index ee6c9d16e..bc6102ac8 100644 --- a/packages/alphatab/src/rendering/effects/LetRingEffectInfo.ts +++ b/packages/alphatab/src/rendering/effects/LetRingEffectInfo.ts @@ -1,11 +1,11 @@ import type { Beat } from '@coderline/alphatab/model/Beat'; +import { NotationElement } from '@coderline/alphatab/NotationSettings'; import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; import { EffectBarGlyphSizing } from '@coderline/alphatab/rendering/EffectBarGlyphSizing'; +import { EffectBandPlacementCategory, EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import type { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; import { LineRangedGlyph } from '@coderline/alphatab/rendering/glyphs/LineRangedGlyph'; -import { EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import type { Settings } from '@coderline/alphatab/Settings'; -import { NotationElement } from '@coderline/alphatab/NotationSettings'; /** * @internal @@ -15,10 +15,6 @@ export class LetRingEffectInfo extends EffectInfo { return NotationElement.EffectLetRing; } - public get canShareBand(): boolean { - return false; - } - public get hideOnMultiTrack(): boolean { return false; } @@ -38,4 +34,7 @@ export class LetRingEffectInfo extends EffectInfo { public canExpand(_from: Beat, _to: Beat): boolean { return true; } + public override get placementCategory(): EffectBandPlacementCategory { + return EffectBandPlacementCategory.Span; + } } diff --git a/packages/alphatab/src/rendering/effects/LyricsEffectInfo.ts b/packages/alphatab/src/rendering/effects/LyricsEffectInfo.ts index 30972c7ce..52072b0ec 100644 --- a/packages/alphatab/src/rendering/effects/LyricsEffectInfo.ts +++ b/packages/alphatab/src/rendering/effects/LyricsEffectInfo.ts @@ -3,7 +3,7 @@ import { NotationElement } from '@coderline/alphatab/NotationSettings'; import { TextAlign } from '@coderline/alphatab/platform/ICanvas'; import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; import { EffectBarGlyphSizing } from '@coderline/alphatab/rendering/EffectBarGlyphSizing'; -import { EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; +import { EffectBandPlacementCategory, EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import type { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; import { LyricsGlyph } from '@coderline/alphatab/rendering/glyphs/LyricsGlyph'; import type { Settings } from '@coderline/alphatab/Settings'; @@ -20,14 +20,14 @@ export class LyricsEffectInfo extends EffectInfo { return false; } - public get canShareBand(): boolean { - return false; - } - public get sizingMode(): EffectBarGlyphSizing { return EffectBarGlyphSizing.SingleOnBeat; } + public override get placementCategory(): EffectBandPlacementCategory { + return EffectBandPlacementCategory.HorizontalRow; + } + public shouldCreateGlyph(_settings: Settings, beat: Beat): boolean { return !!beat.lyrics; } diff --git a/packages/alphatab/src/rendering/effects/MarkerEffectInfo.ts b/packages/alphatab/src/rendering/effects/MarkerEffectInfo.ts index 360ee0e4d..c36988a71 100644 --- a/packages/alphatab/src/rendering/effects/MarkerEffectInfo.ts +++ b/packages/alphatab/src/rendering/effects/MarkerEffectInfo.ts @@ -1,12 +1,12 @@ import type { Beat } from '@coderline/alphatab/model/Beat'; +import { NotationElement } from '@coderline/alphatab/NotationSettings'; import { TextAlign } from '@coderline/alphatab/platform/ICanvas'; import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; import { EffectBarGlyphSizing } from '@coderline/alphatab/rendering/EffectBarGlyphSizing'; +import { EffectBandPlacementCategory, EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import type { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; import { TextGlyph } from '@coderline/alphatab/rendering/glyphs/TextGlyph'; -import { EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import type { Settings } from '@coderline/alphatab/Settings'; -import { NotationElement } from '@coderline/alphatab/NotationSettings'; /** * @internal @@ -20,10 +20,6 @@ export class MarkerEffectInfo extends EffectInfo { return true; } - public get canShareBand(): boolean { - return true; - } - public get sizingMode(): EffectBarGlyphSizing { return EffectBarGlyphSizing.SinglePreBeat; } @@ -52,4 +48,7 @@ export class MarkerEffectInfo extends EffectInfo { public canExpand(_from: Beat, _to: Beat): boolean { return true; } + public override get placementCategory(): EffectBandPlacementCategory { + return EffectBandPlacementCategory.SystemMarker; + } } diff --git a/packages/alphatab/src/rendering/effects/NoteEffectInfoBase.ts b/packages/alphatab/src/rendering/effects/NoteEffectInfoBase.ts index efd85cfa7..1fa1acb69 100644 --- a/packages/alphatab/src/rendering/effects/NoteEffectInfoBase.ts +++ b/packages/alphatab/src/rendering/effects/NoteEffectInfoBase.ts @@ -26,10 +26,6 @@ export abstract class NoteEffectInfoBase extends EffectInfo { return false; } - public get canShareBand(): boolean { - return true; - } - public canExpand(_from: Beat, _to: Beat): boolean { return true; } diff --git a/packages/alphatab/src/rendering/effects/NoteOrnamentEffectInfo.ts b/packages/alphatab/src/rendering/effects/NoteOrnamentEffectInfo.ts index 1121a8b7f..5c0f2d18b 100644 --- a/packages/alphatab/src/rendering/effects/NoteOrnamentEffectInfo.ts +++ b/packages/alphatab/src/rendering/effects/NoteOrnamentEffectInfo.ts @@ -20,10 +20,6 @@ export class NoteOrnamentEffectInfo extends EffectInfo { return false; } - public get canShareBand(): boolean { - return true; - } - public get sizingMode(): EffectBarGlyphSizing { return EffectBarGlyphSizing.SingleOnBeat; } @@ -39,4 +35,8 @@ export class NoteOrnamentEffectInfo extends EffectInfo { public canExpand(_from: Beat, _to: Beat): boolean { return false; } + + public override get contributesToBeatSpacing(): boolean { + return true; + } } diff --git a/packages/alphatab/src/rendering/effects/NumberedBarKeySignatureEffectInfo.ts b/packages/alphatab/src/rendering/effects/NumberedBarKeySignatureEffectInfo.ts index 31a60c593..be32012f7 100644 --- a/packages/alphatab/src/rendering/effects/NumberedBarKeySignatureEffectInfo.ts +++ b/packages/alphatab/src/rendering/effects/NumberedBarKeySignatureEffectInfo.ts @@ -19,10 +19,6 @@ export class NumberedBarKeySignatureEffectInfo extends EffectInfo { return false; } - public get canShareBand(): boolean { - return false; - } - public get sizingMode(): EffectBarGlyphSizing { return EffectBarGlyphSizing.FullBar; } diff --git a/packages/alphatab/src/rendering/effects/OttaviaEffectInfo.ts b/packages/alphatab/src/rendering/effects/OttaviaEffectInfo.ts index 4932f3860..4002e317c 100644 --- a/packages/alphatab/src/rendering/effects/OttaviaEffectInfo.ts +++ b/packages/alphatab/src/rendering/effects/OttaviaEffectInfo.ts @@ -1,12 +1,12 @@ import type { Beat } from '@coderline/alphatab/model/Beat'; import { Ottavia } from '@coderline/alphatab/model/Ottavia'; +import { NotationElement } from '@coderline/alphatab/NotationSettings'; import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; import { EffectBarGlyphSizing } from '@coderline/alphatab/rendering/EffectBarGlyphSizing'; +import { EffectBandPlacementCategory, EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import type { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; import { OttavaGlyph } from '@coderline/alphatab/rendering/glyphs/OttavaGlyph'; -import { EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import type { Settings } from '@coderline/alphatab/Settings'; -import { NotationElement } from '@coderline/alphatab/NotationSettings'; /** * @internal @@ -26,10 +26,6 @@ export class OttaviaEffectInfo extends EffectInfo { return false; } - public get canShareBand(): boolean { - return true; - } - public get sizingMode(): EffectBarGlyphSizing { return EffectBarGlyphSizing.GroupedOnBeat; } @@ -60,4 +56,7 @@ export class OttaviaEffectInfo extends EffectInfo { public canExpand(from: Beat, to: Beat): boolean { return from.ottava === to.ottava; } + public override get placementCategory(): EffectBandPlacementCategory { + return EffectBandPlacementCategory.Span; + } } diff --git a/packages/alphatab/src/rendering/effects/PalmMuteEffectInfo.ts b/packages/alphatab/src/rendering/effects/PalmMuteEffectInfo.ts index 306e66c68..561cf18fc 100644 --- a/packages/alphatab/src/rendering/effects/PalmMuteEffectInfo.ts +++ b/packages/alphatab/src/rendering/effects/PalmMuteEffectInfo.ts @@ -1,11 +1,12 @@ import type { Beat } from '@coderline/alphatab/model/Beat'; import type { Note } from '@coderline/alphatab/model/Note'; +import { NotationElement } from '@coderline/alphatab/NotationSettings'; import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; import { EffectBarGlyphSizing } from '@coderline/alphatab/rendering/EffectBarGlyphSizing'; +import { EffectBandPlacementCategory } from '@coderline/alphatab/rendering/EffectInfo'; import { NoteEffectInfoBase } from '@coderline/alphatab/rendering/effects/NoteEffectInfoBase'; import type { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; import { LineRangedGlyph } from '@coderline/alphatab/rendering/glyphs/LineRangedGlyph'; -import { NotationElement } from '@coderline/alphatab/NotationSettings'; /** * @internal @@ -26,4 +27,7 @@ export class PalmMuteEffectInfo extends NoteEffectInfoBase { public createNewGlyph(_renderer: BarRendererBase, _beat: Beat): EffectGlyph { return new LineRangedGlyph('P.M.', NotationElement.EffectPalmMute); } + public override get placementCategory(): EffectBandPlacementCategory { + return EffectBandPlacementCategory.Span; + } } diff --git a/packages/alphatab/src/rendering/effects/PickStrokeEffectInfo.ts b/packages/alphatab/src/rendering/effects/PickStrokeEffectInfo.ts index 0bcf23525..9629764c6 100644 --- a/packages/alphatab/src/rendering/effects/PickStrokeEffectInfo.ts +++ b/packages/alphatab/src/rendering/effects/PickStrokeEffectInfo.ts @@ -1,12 +1,12 @@ import type { Beat } from '@coderline/alphatab/model/Beat'; import { PickStroke } from '@coderline/alphatab/model/PickStroke'; +import { NotationElement } from '@coderline/alphatab/NotationSettings'; import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; import { EffectBarGlyphSizing } from '@coderline/alphatab/rendering/EffectBarGlyphSizing'; +import { EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import type { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; import { PickStrokeGlyph } from '@coderline/alphatab/rendering/glyphs/PickStrokeGlyph'; -import { EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import type { Settings } from '@coderline/alphatab/Settings'; -import { NotationElement } from '@coderline/alphatab/NotationSettings'; /** * @internal @@ -20,10 +20,6 @@ export class PickStrokeEffectInfo extends EffectInfo { return false; } - public get canShareBand(): boolean { - return true; - } - public get sizingMode(): EffectBarGlyphSizing { return EffectBarGlyphSizing.SingleOnBeat; } diff --git a/packages/alphatab/src/rendering/effects/RasgueadoEffectInfo.ts b/packages/alphatab/src/rendering/effects/RasgueadoEffectInfo.ts index 3219ceb52..b3a19c952 100644 --- a/packages/alphatab/src/rendering/effects/RasgueadoEffectInfo.ts +++ b/packages/alphatab/src/rendering/effects/RasgueadoEffectInfo.ts @@ -1,11 +1,11 @@ import type { Beat } from '@coderline/alphatab/model/Beat'; +import { NotationElement } from '@coderline/alphatab/NotationSettings'; import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; import { EffectBarGlyphSizing } from '@coderline/alphatab/rendering/EffectBarGlyphSizing'; +import { EffectBandPlacementCategory, EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import type { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; import { LineRangedGlyph } from '@coderline/alphatab/rendering/glyphs/LineRangedGlyph'; -import { EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import type { Settings } from '@coderline/alphatab/Settings'; -import { NotationElement } from '@coderline/alphatab/NotationSettings'; /** * @internal @@ -15,10 +15,6 @@ export class RasgueadoEffectInfo extends EffectInfo { return NotationElement.EffectRasgueado; } - public get canShareBand(): boolean { - return false; - } - public get hideOnMultiTrack(): boolean { return false; } @@ -38,4 +34,7 @@ export class RasgueadoEffectInfo extends EffectInfo { public canExpand(_from: Beat, _to: Beat): boolean { return true; } + public override get placementCategory(): EffectBandPlacementCategory { + return EffectBandPlacementCategory.Span; + } } diff --git a/packages/alphatab/src/rendering/effects/SimpleDipWhammyBarEffectInfo.ts b/packages/alphatab/src/rendering/effects/SimpleDipWhammyBarEffectInfo.ts index 5244a4813..2849bfac7 100644 --- a/packages/alphatab/src/rendering/effects/SimpleDipWhammyBarEffectInfo.ts +++ b/packages/alphatab/src/rendering/effects/SimpleDipWhammyBarEffectInfo.ts @@ -3,7 +3,7 @@ import { WhammyType } from '@coderline/alphatab/model/WhammyType'; import { NotationElement, NotationMode } from '@coderline/alphatab/NotationSettings'; import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; import { EffectBarGlyphSizing } from '@coderline/alphatab/rendering/EffectBarGlyphSizing'; -import { EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; +import { EffectBandPlacementCategory, EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import type { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; import { TabWhammyBarGlyph } from '@coderline/alphatab/rendering/glyphs/TabWhammyBarGlyph'; import type { Settings } from '@coderline/alphatab/Settings'; @@ -24,10 +24,6 @@ export class SimpleDipWhammyBarEffectInfo extends EffectInfo { return false; } - public get canShareBand(): boolean { - return false; - } - public get sizingMode(): EffectBarGlyphSizing { return EffectBarGlyphSizing.SingleOnBeat; } @@ -47,4 +43,7 @@ export class SimpleDipWhammyBarEffectInfo extends EffectInfo { public canExpand(_from: Beat, _to: Beat): boolean { return true; } + public override get placementCategory(): EffectBandPlacementCategory { + return EffectBandPlacementCategory.Span; + } } diff --git a/packages/alphatab/src/rendering/effects/SlightBeatVibratoEffectInfo.ts b/packages/alphatab/src/rendering/effects/SlightBeatVibratoEffectInfo.ts index 8553b7b5b..2eae567e1 100644 --- a/packages/alphatab/src/rendering/effects/SlightBeatVibratoEffectInfo.ts +++ b/packages/alphatab/src/rendering/effects/SlightBeatVibratoEffectInfo.ts @@ -1,12 +1,12 @@ import type { Beat } from '@coderline/alphatab/model/Beat'; import { VibratoType } from '@coderline/alphatab/model/VibratoType'; +import { NotationElement } from '@coderline/alphatab/NotationSettings'; import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; import { EffectBarGlyphSizing } from '@coderline/alphatab/rendering/EffectBarGlyphSizing'; +import { EffectBandPlacementCategory, EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import { BeatVibratoGlyph } from '@coderline/alphatab/rendering/glyphs/BeatVibratoGlyph'; import type { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; -import { EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import type { Settings } from '@coderline/alphatab/Settings'; -import { NotationElement } from '@coderline/alphatab/NotationSettings'; /** * @internal @@ -20,10 +20,6 @@ export class SlightBeatVibratoEffectInfo extends EffectInfo { return false; } - public get canShareBand(): boolean { - return true; - } - public get sizingMode(): EffectBarGlyphSizing { return EffectBarGlyphSizing.GroupedOnBeatToEnd; } @@ -39,4 +35,7 @@ export class SlightBeatVibratoEffectInfo extends EffectInfo { public canExpand(_from: Beat, _to: Beat): boolean { return true; } + public override get placementCategory(): EffectBandPlacementCategory { + return EffectBandPlacementCategory.Span; + } } diff --git a/packages/alphatab/src/rendering/effects/SlightNoteVibratoEffectInfo.ts b/packages/alphatab/src/rendering/effects/SlightNoteVibratoEffectInfo.ts index 8eccf3343..25cff338f 100644 --- a/packages/alphatab/src/rendering/effects/SlightNoteVibratoEffectInfo.ts +++ b/packages/alphatab/src/rendering/effects/SlightNoteVibratoEffectInfo.ts @@ -1,12 +1,13 @@ import type { Beat } from '@coderline/alphatab/model/Beat'; import type { Note } from '@coderline/alphatab/model/Note'; import { VibratoType } from '@coderline/alphatab/model/VibratoType'; +import { NotationElement } from '@coderline/alphatab/NotationSettings'; import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; import { EffectBarGlyphSizing } from '@coderline/alphatab/rendering/EffectBarGlyphSizing'; +import { EffectBandPlacementCategory } from '@coderline/alphatab/rendering/EffectInfo'; import { NoteEffectInfoBase } from '@coderline/alphatab/rendering/effects/NoteEffectInfoBase'; import type { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; import { NoteVibratoGlyph } from '@coderline/alphatab/rendering/glyphs/NoteVibratoGlyph'; -import { NotationElement } from '@coderline/alphatab/NotationSettings'; /** * @internal @@ -42,4 +43,7 @@ export class SlightNoteVibratoEffectInfo extends NoteEffectInfoBase { super(); this._hideOnTiedBend = hideOnTiedBend; } + public override get placementCategory(): EffectBandPlacementCategory { + return EffectBandPlacementCategory.Span; + } } diff --git a/packages/alphatab/src/rendering/effects/SustainPedalEffectInfo.ts b/packages/alphatab/src/rendering/effects/SustainPedalEffectInfo.ts index dc024f492..d4a2cbd5f 100644 --- a/packages/alphatab/src/rendering/effects/SustainPedalEffectInfo.ts +++ b/packages/alphatab/src/rendering/effects/SustainPedalEffectInfo.ts @@ -1,11 +1,11 @@ -import { NotationElement } from '@coderline/alphatab/NotationSettings'; -import { EffectBarGlyphSizing } from '@coderline/alphatab/rendering/EffectBarGlyphSizing'; -import type { Settings } from '@coderline/alphatab/Settings'; import type { Beat } from '@coderline/alphatab/model/Beat'; +import { NotationElement } from '@coderline/alphatab/NotationSettings'; import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; +import { EffectBarGlyphSizing } from '@coderline/alphatab/rendering/EffectBarGlyphSizing'; +import { EffectBandPlacementCategory, EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import type { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; -import { EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import { SustainPedalGlyph } from '@coderline/alphatab/rendering/glyphs/SustainPedalGlyph'; +import type { Settings } from '@coderline/alphatab/Settings'; /** * @internal @@ -19,10 +19,6 @@ export class SustainPedalEffectInfo extends EffectInfo { return false; } - public get canShareBand(): boolean { - return false; - } - public get sizingMode(): EffectBarGlyphSizing { return EffectBarGlyphSizing.FullBar; } @@ -38,4 +34,7 @@ export class SustainPedalEffectInfo extends EffectInfo { public canExpand(_from: Beat, _to: Beat): boolean { return true; } + public override get placementCategory(): EffectBandPlacementCategory { + return EffectBandPlacementCategory.Span; + } } diff --git a/packages/alphatab/src/rendering/effects/TabWhammyEffectInfo.ts b/packages/alphatab/src/rendering/effects/TabWhammyEffectInfo.ts index 37068bfb8..4909ff190 100644 --- a/packages/alphatab/src/rendering/effects/TabWhammyEffectInfo.ts +++ b/packages/alphatab/src/rendering/effects/TabWhammyEffectInfo.ts @@ -3,7 +3,7 @@ import { NotationElement } from '@coderline/alphatab/NotationSettings'; import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; import type { EffectBand } from '@coderline/alphatab/rendering/EffectBand'; import { EffectBarGlyphSizing } from '@coderline/alphatab/rendering/EffectBarGlyphSizing'; -import { EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; +import { EffectBandPlacementCategory, EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import type { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; import { TabWhammyBarGlyph } from '@coderline/alphatab/rendering/glyphs/TabWhammyBarGlyph'; import type { Settings } from '@coderline/alphatab/Settings'; @@ -20,10 +20,6 @@ export class TabWhammyEffectInfo extends EffectInfo { return false; } - public get canShareBand(): boolean { - return false; - } - public get sizingMode(): EffectBarGlyphSizing { return EffectBarGlyphSizing.GroupedOnBeatToEnd; } @@ -52,13 +48,15 @@ export class TabWhammyEffectInfo extends EffectInfo { [0, 0] ); band.renderer.staff!.setSharedLayoutData(TabWhammyEffectInfo.offsetSharedDataKey, info); - for (const g of band.iterateAllGlyphs()) { - const tb = g as TabWhammyBarGlyph; - if (tb.originalTopOffset > info[0]) { - info[0] = tb.originalTopOffset; - } - if (tb.originalBottomOffset > info[1]) { - info[1] = tb.originalBottomOffset; + for (const voiceGlyphs of band.glyphsByVoice) { + for (let i = 0, n = voiceGlyphs.length; i < n; i++) { + const tb = voiceGlyphs[i] as TabWhammyBarGlyph; + if (tb.originalTopOffset > info[0]) { + info[0] = tb.originalTopOffset; + } + if (tb.originalBottomOffset > info[1]) { + info[1] = tb.originalBottomOffset; + } } } } @@ -70,13 +68,17 @@ export class TabWhammyEffectInfo extends EffectInfo { ); const top = info[0]; const bottom = info[1]; - for (const g of band.iterateAllGlyphs()) { - const tb = g as TabWhammyBarGlyph; - tb.topOffset = top; - tb.bottomOffset = bottom; - tb.height = top + bottom; + for (const voiceGlyphs of band.glyphsByVoice) { + for (let i = 0, n = voiceGlyphs.length; i < n; i++) { + const tb = voiceGlyphs[i] as TabWhammyBarGlyph; + tb.topOffset = top; + tb.bottomOffset = bottom; + tb.height = top + bottom; + } } - band.slot!.shared.height = top + bottom; band.height = top + bottom; } + public override get placementCategory(): EffectBandPlacementCategory { + return EffectBandPlacementCategory.Span; + } } diff --git a/packages/alphatab/src/rendering/effects/TapEffectInfo.ts b/packages/alphatab/src/rendering/effects/TapEffectInfo.ts index 9a494642d..456b03859 100644 --- a/packages/alphatab/src/rendering/effects/TapEffectInfo.ts +++ b/packages/alphatab/src/rendering/effects/TapEffectInfo.ts @@ -1,13 +1,13 @@ import type { Beat } from '@coderline/alphatab/model/Beat'; +import { NotationElement } from '@coderline/alphatab/NotationSettings'; import { TextAlign } from '@coderline/alphatab/platform/ICanvas'; +import type { RenderingResources } from '@coderline/alphatab/RenderingResources'; import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; import { EffectBarGlyphSizing } from '@coderline/alphatab/rendering/EffectBarGlyphSizing'; +import { EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import type { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; import { TextGlyph } from '@coderline/alphatab/rendering/glyphs/TextGlyph'; -import { EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; -import type { RenderingResources } from '@coderline/alphatab/RenderingResources'; import type { Settings } from '@coderline/alphatab/Settings'; -import { NotationElement } from '@coderline/alphatab/NotationSettings'; /** * @internal @@ -21,10 +21,6 @@ export class TapEffectInfo extends EffectInfo { return false; } - public get canShareBand(): boolean { - return true; - } - public get sizingMode(): EffectBarGlyphSizing { return EffectBarGlyphSizing.SingleOnBeat; } diff --git a/packages/alphatab/src/rendering/effects/TempoEffectInfo.ts b/packages/alphatab/src/rendering/effects/TempoEffectInfo.ts index fe0092996..17cb38cba 100644 --- a/packages/alphatab/src/rendering/effects/TempoEffectInfo.ts +++ b/packages/alphatab/src/rendering/effects/TempoEffectInfo.ts @@ -1,11 +1,11 @@ import type { Beat } from '@coderline/alphatab/model/Beat'; +import { NotationElement } from '@coderline/alphatab/NotationSettings'; import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; import { EffectBarGlyphSizing } from '@coderline/alphatab/rendering/EffectBarGlyphSizing'; +import { EffectBandPlacementCategory, EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; +import { BarTempoGlyph } from '@coderline/alphatab/rendering/glyphs/BarTempoGlyph'; import type { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; -import { EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import type { Settings } from '@coderline/alphatab/Settings'; -import { NotationElement } from '@coderline/alphatab/NotationSettings'; -import { BarTempoGlyph } from '@coderline/alphatab/rendering/glyphs/BarTempoGlyph'; /** * @internal @@ -19,10 +19,6 @@ export class TempoEffectInfo extends EffectInfo { return true; } - public get canShareBand(): boolean { - return false; - } - public get sizingMode(): EffectBarGlyphSizing { return EffectBarGlyphSizing.SinglePreBeat; } @@ -43,4 +39,7 @@ export class TempoEffectInfo extends EffectInfo { public canExpand(_from: Beat, _to: Beat): boolean { return true; } + public override get placementCategory(): EffectBandPlacementCategory { + return EffectBandPlacementCategory.SystemMarker; + } } diff --git a/packages/alphatab/src/rendering/effects/TextEffectInfo.ts b/packages/alphatab/src/rendering/effects/TextEffectInfo.ts index e9efe13f9..ac99a41e1 100644 --- a/packages/alphatab/src/rendering/effects/TextEffectInfo.ts +++ b/packages/alphatab/src/rendering/effects/TextEffectInfo.ts @@ -1,12 +1,12 @@ import type { Beat } from '@coderline/alphatab/model/Beat'; +import { NotationElement } from '@coderline/alphatab/NotationSettings'; import { TextAlign } from '@coderline/alphatab/platform/ICanvas'; import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; import { EffectBarGlyphSizing } from '@coderline/alphatab/rendering/EffectBarGlyphSizing'; +import { EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import type { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; import { TextGlyph } from '@coderline/alphatab/rendering/glyphs/TextGlyph'; -import { EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import type { Settings } from '@coderline/alphatab/Settings'; -import { NotationElement } from '@coderline/alphatab/NotationSettings'; /** * @internal @@ -20,10 +20,6 @@ export class TextEffectInfo extends EffectInfo { return false; } - public get canShareBand(): boolean { - return false; - } - public get sizingMode(): EffectBarGlyphSizing { return EffectBarGlyphSizing.SingleOnBeat; } diff --git a/packages/alphatab/src/rendering/effects/TrillEffectInfo.ts b/packages/alphatab/src/rendering/effects/TrillEffectInfo.ts index 16355682e..7aa907395 100644 --- a/packages/alphatab/src/rendering/effects/TrillEffectInfo.ts +++ b/packages/alphatab/src/rendering/effects/TrillEffectInfo.ts @@ -1,11 +1,12 @@ import type { Beat } from '@coderline/alphatab/model/Beat'; import type { Note } from '@coderline/alphatab/model/Note'; +import { NotationElement } from '@coderline/alphatab/NotationSettings'; import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; import { EffectBarGlyphSizing } from '@coderline/alphatab/rendering/EffectBarGlyphSizing'; +import { EffectBandPlacementCategory } from '@coderline/alphatab/rendering/EffectInfo'; import { NoteEffectInfoBase } from '@coderline/alphatab/rendering/effects/NoteEffectInfoBase'; import type { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; import { TrillGlyph } from '@coderline/alphatab/rendering/glyphs/TrillGlyph'; -import { NotationElement } from '@coderline/alphatab/NotationSettings'; /** * @internal @@ -26,4 +27,7 @@ export class TrillEffectInfo extends NoteEffectInfoBase { public createNewGlyph(_renderer: BarRendererBase, _beat: Beat): EffectGlyph { return new TrillGlyph(0, 0); } + public override get placementCategory(): EffectBandPlacementCategory { + return EffectBandPlacementCategory.Span; + } } diff --git a/packages/alphatab/src/rendering/effects/TripletFeelEffectInfo.ts b/packages/alphatab/src/rendering/effects/TripletFeelEffectInfo.ts index 3966742f7..4e1bbd6ff 100644 --- a/packages/alphatab/src/rendering/effects/TripletFeelEffectInfo.ts +++ b/packages/alphatab/src/rendering/effects/TripletFeelEffectInfo.ts @@ -1,12 +1,12 @@ import type { Beat } from '@coderline/alphatab/model/Beat'; import { TripletFeel } from '@coderline/alphatab/model/TripletFeel'; +import { NotationElement } from '@coderline/alphatab/NotationSettings'; import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; import { EffectBarGlyphSizing } from '@coderline/alphatab/rendering/EffectBarGlyphSizing'; +import { EffectBandPlacementCategory, EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import type { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; import { TripletFeelGlyph } from '@coderline/alphatab/rendering/glyphs/TripletFeelGlyph'; -import { EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import type { Settings } from '@coderline/alphatab/Settings'; -import { NotationElement } from '@coderline/alphatab/NotationSettings'; /** * @internal @@ -20,10 +20,6 @@ export class TripletFeelEffectInfo extends EffectInfo { return true; } - public get canShareBand(): boolean { - return false; - } - public get sizingMode(): EffectBarGlyphSizing { return EffectBarGlyphSizing.SinglePreBeat; } @@ -45,4 +41,7 @@ export class TripletFeelEffectInfo extends EffectInfo { public canExpand(_from: Beat, _to: Beat): boolean { return true; } + public override get placementCategory(): EffectBandPlacementCategory { + return EffectBandPlacementCategory.SystemMarker; + } } diff --git a/packages/alphatab/src/rendering/effects/WahPedalEffectInfo.ts b/packages/alphatab/src/rendering/effects/WahPedalEffectInfo.ts index 810385fb6..78a8c701b 100644 --- a/packages/alphatab/src/rendering/effects/WahPedalEffectInfo.ts +++ b/packages/alphatab/src/rendering/effects/WahPedalEffectInfo.ts @@ -3,7 +3,7 @@ import { WahPedal } from '@coderline/alphatab/model/WahPedal'; import { NotationElement } from '@coderline/alphatab/NotationSettings'; import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; import { EffectBarGlyphSizing } from '@coderline/alphatab/rendering/EffectBarGlyphSizing'; -import { EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; +import { EffectBandPlacementCategory, EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import type { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; import { WahPedalGlyph } from '@coderline/alphatab/rendering/glyphs/WahPedalGlyph'; import type { Settings } from '@coderline/alphatab/Settings'; @@ -20,10 +20,6 @@ export class WahPedalEffectInfo extends EffectInfo { return false; } - public get canShareBand(): boolean { - return true; - } - public get sizingMode(): EffectBarGlyphSizing { return EffectBarGlyphSizing.SingleOnBeat; } @@ -39,4 +35,7 @@ export class WahPedalEffectInfo extends EffectInfo { public canExpand(_from: Beat, _to: Beat): boolean { return false; } + public override get placementCategory(): EffectBandPlacementCategory { + return EffectBandPlacementCategory.Span; + } } diff --git a/packages/alphatab/src/rendering/effects/WhammyBarEffectInfo.ts b/packages/alphatab/src/rendering/effects/WhammyBarEffectInfo.ts index 75a10a1e8..1269ff75c 100644 --- a/packages/alphatab/src/rendering/effects/WhammyBarEffectInfo.ts +++ b/packages/alphatab/src/rendering/effects/WhammyBarEffectInfo.ts @@ -1,11 +1,11 @@ import type { Beat } from '@coderline/alphatab/model/Beat'; +import { NotationElement } from '@coderline/alphatab/NotationSettings'; import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; import { EffectBarGlyphSizing } from '@coderline/alphatab/rendering/EffectBarGlyphSizing'; +import { EffectBandPlacementCategory, EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import type { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; import { LineRangedGlyph } from '@coderline/alphatab/rendering/glyphs/LineRangedGlyph'; -import { EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import type { Settings } from '@coderline/alphatab/Settings'; -import { NotationElement } from '@coderline/alphatab/NotationSettings'; /** * @internal @@ -19,10 +19,6 @@ export class WhammyBarEffectInfo extends EffectInfo { return false; } - public get canShareBand(): boolean { - return false; - } - public get sizingMode(): EffectBarGlyphSizing { return EffectBarGlyphSizing.GroupedOnBeat; } @@ -38,4 +34,7 @@ export class WhammyBarEffectInfo extends EffectInfo { public canExpand(_from: Beat, _to: Beat): boolean { return true; } + public override get placementCategory(): EffectBandPlacementCategory { + return EffectBandPlacementCategory.Span; + } } diff --git a/packages/alphatab/src/rendering/effects/WideBeatVibratoEffectInfo.ts b/packages/alphatab/src/rendering/effects/WideBeatVibratoEffectInfo.ts index 8035c9d17..771a26a2f 100644 --- a/packages/alphatab/src/rendering/effects/WideBeatVibratoEffectInfo.ts +++ b/packages/alphatab/src/rendering/effects/WideBeatVibratoEffectInfo.ts @@ -1,12 +1,12 @@ import type { Beat } from '@coderline/alphatab/model/Beat'; import { VibratoType } from '@coderline/alphatab/model/VibratoType'; +import { NotationElement } from '@coderline/alphatab/NotationSettings'; import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; import { EffectBarGlyphSizing } from '@coderline/alphatab/rendering/EffectBarGlyphSizing'; +import { EffectBandPlacementCategory, EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import { BeatVibratoGlyph } from '@coderline/alphatab/rendering/glyphs/BeatVibratoGlyph'; import type { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; -import { EffectInfo } from '@coderline/alphatab/rendering/EffectInfo'; import type { Settings } from '@coderline/alphatab/Settings'; -import { NotationElement } from '@coderline/alphatab/NotationSettings'; /** * @internal @@ -20,10 +20,6 @@ export class WideBeatVibratoEffectInfo extends EffectInfo { return false; } - public get canShareBand(): boolean { - return true; - } - public get sizingMode(): EffectBarGlyphSizing { return EffectBarGlyphSizing.GroupedOnBeatToEnd; } @@ -39,4 +35,7 @@ export class WideBeatVibratoEffectInfo extends EffectInfo { public canExpand(_from: Beat, _to: Beat): boolean { return true; } + public override get placementCategory(): EffectBandPlacementCategory { + return EffectBandPlacementCategory.Span; + } } diff --git a/packages/alphatab/src/rendering/effects/WideNoteVibratoEffectInfo.ts b/packages/alphatab/src/rendering/effects/WideNoteVibratoEffectInfo.ts index 2d1d32c86..cfa95d1f6 100644 --- a/packages/alphatab/src/rendering/effects/WideNoteVibratoEffectInfo.ts +++ b/packages/alphatab/src/rendering/effects/WideNoteVibratoEffectInfo.ts @@ -1,12 +1,13 @@ import type { Beat } from '@coderline/alphatab/model/Beat'; import type { Note } from '@coderline/alphatab/model/Note'; import { VibratoType } from '@coderline/alphatab/model/VibratoType'; +import { NotationElement } from '@coderline/alphatab/NotationSettings'; import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; import { EffectBarGlyphSizing } from '@coderline/alphatab/rendering/EffectBarGlyphSizing'; +import { EffectBandPlacementCategory } from '@coderline/alphatab/rendering/EffectInfo'; import { NoteEffectInfoBase } from '@coderline/alphatab/rendering/effects/NoteEffectInfoBase'; import type { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; import { NoteVibratoGlyph } from '@coderline/alphatab/rendering/glyphs/NoteVibratoGlyph'; -import { NotationElement } from '@coderline/alphatab/NotationSettings'; /** * @internal @@ -29,4 +30,7 @@ export class WideNoteVibratoEffectInfo extends NoteEffectInfoBase { public createNewGlyph(_renderer: BarRendererBase, _beat: Beat): EffectGlyph { return new NoteVibratoGlyph(0, 0, VibratoType.Wide); } + public override get placementCategory(): EffectBandPlacementCategory { + return EffectBandPlacementCategory.Span; + } } diff --git a/packages/alphatab/src/rendering/glyphs/BarNumberGlyph.ts b/packages/alphatab/src/rendering/glyphs/BarNumberGlyph.ts index c6fb37b41..81bbfae2b 100644 --- a/packages/alphatab/src/rendering/glyphs/BarNumberGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/BarNumberGlyph.ts @@ -1,9 +1,9 @@ -import { TextBaseline, type ICanvas } from '@coderline/alphatab/platform/ICanvas'; +import { NotationElement } from '@coderline/alphatab/NotationSettings'; +import { type ICanvas, TextBaseline } from '@coderline/alphatab/platform/ICanvas'; import type { RenderingResources } from '@coderline/alphatab/RenderingResources'; import { Glyph } from '@coderline/alphatab/rendering/glyphs/Glyph'; import type { LineBarRenderer } from '@coderline/alphatab/rendering/LineBarRenderer'; import { ElementStyleHelper } from '@coderline/alphatab/rendering/utils/ElementStyleHelper'; -import { NotationElement } from '@coderline/alphatab/NotationSettings'; /** * @internal @@ -24,6 +24,25 @@ export class BarNumberGlyph extends Glyph { this.y -= this.height; } + public override populateSkyline(): void { + this.renderer.insertSkylineFromBbox(this); + } + + /** Collapse bbox on non-first staves so the per-x skyline doesn't see a phantom obstacle. */ + public override getBoundingBoxLeft(): number { + if (!this.renderer.staff!.isFirstInSystem) { + return this.x; + } + return super.getBoundingBoxLeft(); + } + + public override getBoundingBoxRight(): number { + if (!this.renderer.staff!.isFirstInSystem) { + return this.x; + } + return super.getBoundingBoxRight(); + } + public override paint(cx: number, cy: number, canvas: ICanvas): void { if (!this.renderer.staff!.isFirstInSystem) { return; diff --git a/packages/alphatab/src/rendering/glyphs/BarTempoGlyph.ts b/packages/alphatab/src/rendering/glyphs/BarTempoGlyph.ts index 33eded0e5..8ed588257 100644 --- a/packages/alphatab/src/rendering/glyphs/BarTempoGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/BarTempoGlyph.ts @@ -1,9 +1,20 @@ import type { Automation } from '@coderline/alphatab/model/Automation'; import { MusicFontSymbol } from '@coderline/alphatab/model/MusicFontSymbol'; import { NotationElement } from '@coderline/alphatab/NotationSettings'; -import { CanvasHelper, TextBaseline, type ICanvas } from '@coderline/alphatab/platform/ICanvas'; +import { CanvasHelper, type ICanvas, TextBaseline } from '@coderline/alphatab/platform/ICanvas'; import { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; +/** + * @record + * @internal + */ +interface TempoAutomationLayout { + textWidth: number; + valueWidth: number; + textPrefix: string; + valuePostfix: string; +} + /** * This glyph renders tempo annotations for tempo automations * where the drawing position is determined more dynamically while rendering. @@ -12,21 +23,109 @@ import { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; export class BarTempoGlyph extends EffectGlyph { private _tempoAutomations: Automation[]; + private _automationLayouts: TempoAutomationLayout[] = []; + private _symbolWidth: number = 0; + private _noteShift: number = 0; + + // Bbox cache; invalidated in doLayout and in populateSkyline (post-scaleToWidth). + private _cachedBoundingBoxLeft: number = 0; + private _cachedBoundingBoxRight: number = 0; + private _cachedBoundingBoxLeftValid: boolean = false; + private _cachedBoundingBoxRightValid: boolean = false; + public constructor(tempoAutomations: Automation[]) { super(0, 0); this._tempoAutomations = tempoAutomations; } public override doLayout(): void { + this._cachedBoundingBoxLeftValid = false; + this._cachedBoundingBoxRightValid = false; super.doLayout(); const res = this.renderer.resources; - this.height = - this.renderer.smuflMetrics.glyphHeights.get(MusicFontSymbol.MetNoteQuarterUp)! * - res.engravingSettings.tempoNoteScale; + const scale = res.engravingSettings.tempoNoteScale; + this._symbolWidth = this.renderer.smuflMetrics.glyphWidths.get(MusicFontSymbol.MetNoteQuarterUp)! * scale; + // Engraving-settings width (matches the text-less branch in paint), not smufl metric. + this._noteShift = res.engravingSettings.glyphWidths.get(MusicFontSymbol.MetNoteQuarterUp)! / 2; + this.height = this.renderer.smuflMetrics.glyphHeights.get(MusicFontSymbol.MetNoteQuarterUp)! * scale; + + const canvas = this.renderer.scoreRenderer.canvas!; + canvas.font = res.elementFonts.get(NotationElement.EffectMarker)!; + this._automationLayouts = []; + for (const automation of this._tempoAutomations) { + // Pre-format paint-time strings once; model is immutable per render. + const textPrefix = automation.text ? `${automation.text} ` : ''; + const valuePostfix = ` = ${automation.value.toString()}`; + const textWidth = automation.text ? canvas.measureText(textPrefix).width : 0; + const valueWidth = canvas.measureText(valuePostfix).width; + const layout: TempoAutomationLayout = { + textWidth: textWidth, + valueWidth: valueWidth, + textPrefix: textPrefix, + valuePostfix: valuePostfix + }; + this._automationLayouts.push(layout); + } + } + + public override getBoundingBoxLeft(): number { + if (this._cachedBoundingBoxLeftValid) { + return this._cachedBoundingBoxLeft; + } + let min = 0; + let found = false; + for (const a of this._tempoAutomations) { + let startX = this.renderer.getRatioPositionX(a.ratioPosition); + if (!a.text) { + startX -= this._noteShift; + } + if (!found || startX < min) { + min = startX; + found = true; + } + } + const result = found ? min : this.x; + this._cachedBoundingBoxLeft = result; + this._cachedBoundingBoxLeftValid = true; + return result; + } + + public override getBoundingBoxRight(): number { + if (this._cachedBoundingBoxRightValid) { + return this._cachedBoundingBoxRight; + } + let max = 0; + let found = false; + for (let i = 0; i < this._tempoAutomations.length; i++) { + const a = this._tempoAutomations[i]; + const layout = this._automationLayouts[i]; + let startX = this.renderer.getRatioPositionX(a.ratioPosition); + if (!a.text) { + startX -= this._noteShift; + } + const rightX = startX + layout.textWidth + this._symbolWidth + layout.valueWidth; + if (!found || rightX > max) { + max = rightX; + found = true; + } + } + const result = found ? max : this.x; + this._cachedBoundingBoxRight = result; + this._cachedBoundingBoxRightValid = true; + return result; + } + + public override populateSkyline(): void { + // Post-scaleToWidth: invalidate cache so insert reads final extents. + this._cachedBoundingBoxLeftValid = false; + this._cachedBoundingBoxRightValid = false; + this.renderer.insertSkylineFromBbox(this); } public override paint(cx: number, cy: number, canvas: ICanvas): void { - for (const automation of this._tempoAutomations) { + for (let i = 0; i < this._tempoAutomations.length; i++) { + const automation = this._tempoAutomations[i]; + const layout = this._automationLayouts[i]; let x = cx + this.renderer.getRatioPositionX(automation.ratioPosition); const res = this.renderer.resources; @@ -42,10 +141,8 @@ export class BarTempoGlyph extends EffectGlyph { const b = canvas.textBaseline; canvas.textBaseline = TextBaseline.Alphabetic; if (automation.text) { - const text = `${automation.text} `; // additional space - const size = canvas.measureText(text); - canvas.fillText(text, x, notePosY); - x += size.width; + canvas.fillText(layout.textPrefix, x, notePosY); + x += layout.textWidth; } else { x -= res.engravingSettings.glyphWidths.get(MusicFontSymbol.MetNoteQuarterUp)! / 2; } @@ -61,10 +158,10 @@ export class BarTempoGlyph extends EffectGlyph { this.renderer.smuflMetrics.glyphWidths.get(MusicFontSymbol.MetNoteQuarterUp)! * res.engravingSettings.tempoNoteScale; - canvas.fillText(` = ${automation.value.toString()}`, x, notePosY); + canvas.fillText(layout.valuePostfix, x, notePosY); canvas.textBaseline = b; - x += canvas.measureText(` = ${automation.value.toString()}`).width; + x += layout.valueWidth; } } } diff --git a/packages/alphatab/src/rendering/glyphs/BeatContainerGlyph.ts b/packages/alphatab/src/rendering/glyphs/BeatContainerGlyph.ts index c34d1e685..bab81bee5 100644 --- a/packages/alphatab/src/rendering/glyphs/BeatContainerGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/BeatContainerGlyph.ts @@ -18,10 +18,32 @@ import type { BeamingHelper } from '@coderline/alphatab/rendering/utils/BeamingH import { BeatBounds } from '@coderline/alphatab/rendering/utils/BeatBounds'; import { Bounds } from '@coderline/alphatab/rendering/utils/Bounds'; +/** + * Per-beat effect-glyph overflow; consumed by the per-beat skyline emission + * walk in {@link BarRendererBase.scaleToWidth}. + * + * @record + * @internal + */ +export interface BeatEffectOverflow { + minY: number; + maxY: number; +} + /** * @internal */ export abstract class BeatContainerGlyphBase extends Glyph { + public pendingEffectOverflows: BeatEffectOverflow[] = []; + + /** Drain pending overflows before the next producer pass; consumer may not run. */ + public prepareForOverflowPass(): void { + const pending = this.pendingEffectOverflows; + if (pending.length > 0) { + pending.splice(0, pending.length); + } + } + public abstract get beatId(): number; public abstract get absoluteDisplayStart(): number; public abstract get displayDuration(): number; diff --git a/packages/alphatab/src/rendering/glyphs/BeatTimerGlyph.ts b/packages/alphatab/src/rendering/glyphs/BeatTimerGlyph.ts index 8de7e9f8b..9d0383045 100644 --- a/packages/alphatab/src/rendering/glyphs/BeatTimerGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/BeatTimerGlyph.ts @@ -1,5 +1,5 @@ import { NotationElement } from '@coderline/alphatab/NotationSettings'; -import { type ICanvas, TextBaseline, TextAlign } from '@coderline/alphatab/platform/ICanvas'; +import { type ICanvas, TextAlign, TextBaseline } from '@coderline/alphatab/platform/ICanvas'; import { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; /** @@ -31,6 +31,14 @@ export class BeatTimerGlyph extends EffectGlyph { this.height = this._textHeight + this.renderer.smuflMetrics.beatTimerPadding * 2; } + public override getBoundingBoxLeft(): number { + return this.x - this._textWidth / 2; + } + + public override getBoundingBoxRight(): number { + return this.x + this._textWidth / 2; + } + public override paint(cx: number, cy: number, canvas: ICanvas): void { const halfWidth = (this._textWidth / 2) | 0; canvas.strokeRect( diff --git a/packages/alphatab/src/rendering/glyphs/DirectionsContainerGlyph.ts b/packages/alphatab/src/rendering/glyphs/DirectionsContainerGlyph.ts index 8fe0c7ec5..162510291 100644 --- a/packages/alphatab/src/rendering/glyphs/DirectionsContainerGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/DirectionsContainerGlyph.ts @@ -1,9 +1,9 @@ import { Direction } from '@coderline/alphatab/model/Direction'; -import { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; -import { Glyph } from '@coderline/alphatab/rendering/glyphs/Glyph'; import { MusicFontSymbol } from '@coderline/alphatab/model/MusicFontSymbol'; -import { type ICanvas, TextBaseline, TextAlign } from '@coderline/alphatab/platform/ICanvas'; import { NotationElement } from '@coderline/alphatab/NotationSettings'; +import { type ICanvas, TextAlign, TextBaseline } from '@coderline/alphatab/platform/ICanvas'; +import { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; +import { Glyph } from '@coderline/alphatab/rendering/glyphs/Glyph'; /** * @internal @@ -22,12 +22,15 @@ class TargetDirectionGlyph extends Glyph { // NOTE: It's nowhere documented explicitly in SMuFL but it appears direction symbols need to be scaled down const scale = this.renderer.smuflMetrics.directionsScale; this._scale = scale; + let totalWidth = 0; for (const s of this._symbols) { const h = this.renderer.smuflMetrics.glyphHeights.get(s)! * scale; if (h > this.height) { this.height = h; } + totalWidth += this.renderer.smuflMetrics.glyphWidths.get(s)! * scale; } + this.width = totalWidth; } public override paint(cx: number, cy: number, canvas: ICanvas): void { @@ -49,7 +52,9 @@ class JumpDirectionGlyph extends Glyph { public override doLayout(): void { const c = this.renderer.scoreRenderer.canvas!; c.font = this.renderer.resources.elementFonts.get(NotationElement.EffectDirections)!; - this.height = c.measureText(this._text).height; + const m = c.measureText(this._text); + this.height = m.height; + this.width = m.width; } public override paint(cx: number, cy: number, canvas: ICanvas): void { @@ -169,6 +174,29 @@ export class DirectionsContainerGlyph extends EffectGlyph { return y; } + /** End-of-bar jump text (`D.C. al Coda`, …) may paint past either bar edge on narrow bars. */ + public override getBoundingBoxLeft(): number { + let min = this.x; + for (const g of this._barEndGlyphs) { + const left = this.x + this.width - g.width; + if (left < min) { + min = left; + } + } + return min; + } + + public override getBoundingBoxRight(): number { + let max = this.x + this.width; + for (const g of this._barBeginGlyphs) { + const right = this.x + g.width; + if (right > max) { + max = right; + } + } + return max; + } + public override paint(cx: number, cy: number, canvas: ICanvas): void { for (const begin of this._barBeginGlyphs) { begin.paint(cx + this.x, cy + this.y, canvas); diff --git a/packages/alphatab/src/rendering/glyphs/EffectGlyph.ts b/packages/alphatab/src/rendering/glyphs/EffectGlyph.ts index 9419b4b68..61bbf60b0 100644 --- a/packages/alphatab/src/rendering/glyphs/EffectGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/EffectGlyph.ts @@ -1,4 +1,5 @@ import type { Beat } from '@coderline/alphatab/model/Beat'; +import type { EffectBand } from '@coderline/alphatab/rendering/EffectBand'; import { Glyph } from '@coderline/alphatab/rendering/glyphs/Glyph'; /** @@ -24,6 +25,12 @@ export class EffectGlyph extends Glyph { */ public previousGlyph: EffectGlyph | null = null; + /** + * Back-reference to the owning {@link EffectBand}, set when the band + * creates the glyph. + */ + public band: EffectBand | null = null; + public constructor(x: number = 0, y: number = 0) { super(x, y); } diff --git a/packages/alphatab/src/rendering/glyphs/FermataGlyph.ts b/packages/alphatab/src/rendering/glyphs/FermataGlyph.ts index 71be0dd98..ee138a706 100644 --- a/packages/alphatab/src/rendering/glyphs/FermataGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/FermataGlyph.ts @@ -1,7 +1,6 @@ import { FermataType } from '@coderline/alphatab/model/Fermata'; -import type { ICanvas } from '@coderline/alphatab/platform/ICanvas'; -import { MusicFontGlyph } from '@coderline/alphatab/rendering/glyphs/MusicFontGlyph'; import { MusicFontSymbol } from '@coderline/alphatab/model/MusicFontSymbol'; +import { MusicFontGlyph } from '@coderline/alphatab/rendering/glyphs/MusicFontGlyph'; /** * @internal @@ -24,7 +23,9 @@ export class FermataGlyph extends MusicFontGlyph { } } - public override paint(cx: number, cy: number, canvas: ICanvas): void { - super.paint(cx - this.width / 2, cy + this.height, canvas); + public override doLayout(): void { + super.doLayout(); + this.center = true; + this.offsetY = this.height; } } diff --git a/packages/alphatab/src/rendering/glyphs/Glyph.ts b/packages/alphatab/src/rendering/glyphs/Glyph.ts index 0a09aa735..10069ca88 100644 --- a/packages/alphatab/src/rendering/glyphs/Glyph.ts +++ b/packages/alphatab/src/rendering/glyphs/Glyph.ts @@ -26,10 +26,27 @@ export class Glyph { return this.getBoundingBoxTop() + this.height; } + /** + * Paint extent — distinct from the rhythmic-spacing extent (`x`, `x + width`). + * Override on zero-width "no-rod" glyphs so the bar-local skyline still sees them. + */ + public getBoundingBoxLeft(): number { + return this.x; + } + + public getBoundingBoxRight(): number { + return this.x + this.width; + } + public doLayout(): void { // to be implemented in subclass } + /** Hook for glyphs whose bbox is only final after `scaleToWidth`. Default no-op. */ + public populateSkyline(): void { + // to be implemented in subclass + } + public paint(_cx: number, _cy: number, _canvas: ICanvas): void { // to be implemented in subclass } diff --git a/packages/alphatab/src/rendering/glyphs/GroupedEffectGlyph.ts b/packages/alphatab/src/rendering/glyphs/GroupedEffectGlyph.ts index e6994b7d6..d7605b32d 100644 --- a/packages/alphatab/src/rendering/glyphs/GroupedEffectGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/GroupedEffectGlyph.ts @@ -17,6 +17,35 @@ export abstract class GroupedEffectGlyph extends EffectGlyph { this.endPosition = endPosition; } + public override getBoundingBoxRight(): number { + if (!this.beat) { + return super.getBoundingBoxRight(); + } + return this.renderer.getBeatX(this.beat, this.endPosition); + } + + /** + * Publishes the chain's true cross-renderer painted xEnd to the owning + * {@link EffectBand} so its xRange covers intermediate columns. Called by + * {@link EffectBand.finalizeChainSpans} on chain heads only. + */ + public publishChainSpan(): void { + if (!this.isLinkedWithNext) { + return; + } + let last: GroupedEffectGlyph = this.nextGlyph as GroupedEffectGlyph; + while (last.isLinkedWithNext) { + last = last.nextGlyph as GroupedEffectGlyph; + } + const trueEndXStaff = last.renderer.x + last.renderer.getBeatX(last.beat!, this.endPosition); + const trueEndXLocal = trueEndXStaff - this.renderer.x; + const xStart = this.getBoundingBoxLeft(); + if (trueEndXLocal <= xStart) { + return; + } + this.band?.publishSpanRange(xStart, trueEndXLocal); + } + public get isLinkedWithPrevious(): boolean { return !!this.previousGlyph && this.previousGlyph.renderer.staff?.system === this.renderer.staff!.system; } diff --git a/packages/alphatab/src/rendering/glyphs/LineRangedGlyph.ts b/packages/alphatab/src/rendering/glyphs/LineRangedGlyph.ts index bdd4b9786..2a5cbee16 100644 --- a/packages/alphatab/src/rendering/glyphs/LineRangedGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/LineRangedGlyph.ts @@ -1,8 +1,8 @@ import type { NotationElement } from '@coderline/alphatab/NotationSettings'; -import { TextBaseline, type ICanvas } from '@coderline/alphatab/platform/ICanvas'; +import { type ICanvas, TextBaseline } from '@coderline/alphatab/platform/ICanvas'; +import type { RenderingResources } from '@coderline/alphatab/RenderingResources'; import { BeatXPosition } from '@coderline/alphatab/rendering/BeatXPosition'; import { GroupedEffectGlyph } from '@coderline/alphatab/rendering/glyphs/GroupedEffectGlyph'; -import type { RenderingResources } from '@coderline/alphatab/RenderingResources'; /** * @internal @@ -32,6 +32,16 @@ export class LineRangedGlyph extends GroupedEffectGlyph { this._labelWidth = size.width; } + public override getBoundingBoxLeft(): number { + return this.x - this._labelWidth / 2; + } + + public override getBoundingBoxRight(): number { + const labelRight = this.x + this._labelWidth / 2; + const groupedRight = super.getBoundingBoxRight(); + return labelRight > groupedRight ? labelRight : groupedRight; + } + protected override paintNonGrouped(cx: number, cy: number, canvas: ICanvas): void { const res: RenderingResources = this.renderer.resources; canvas.font = res.elementFonts.get(this._fontElement)!; diff --git a/packages/alphatab/src/rendering/glyphs/LyricsGlyph.ts b/packages/alphatab/src/rendering/glyphs/LyricsGlyph.ts index 61841b4c2..223887a2b 100644 --- a/packages/alphatab/src/rendering/glyphs/LyricsGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/LyricsGlyph.ts @@ -8,6 +8,7 @@ import { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; export class LyricsGlyph extends EffectGlyph { private _lines: string[]; private _linePositions: number[] = []; + private _maxLineWidth: number = 0; public font: Font; public textAlign: TextAlign; @@ -27,14 +28,41 @@ export class LyricsGlyph extends EffectGlyph { const canvas = this.renderer.scoreRenderer.canvas!; canvas.font = this.font; let y = 0; + let maxWidth = 0; for (const line of this._lines) { this._linePositions.push(y); const size = canvas.measureText(line.length > 0 ? line : ' '); y += size.height + lineSpacing; + if (size.width > maxWidth) { + maxWidth = size.width; + } } y -= lineSpacing; this.height = y; + this._maxLineWidth = maxWidth; + } + + public override getBoundingBoxLeft(): number { + switch (this.textAlign) { + case TextAlign.Center: + return this.x - this._maxLineWidth / 2; + case TextAlign.Right: + return this.x - this._maxLineWidth; + default: + return this.x; + } + } + + public override getBoundingBoxRight(): number { + switch (this.textAlign) { + case TextAlign.Center: + return this.x + this._maxLineWidth / 2; + case TextAlign.Right: + return this.x; + default: + return this.x + this._maxLineWidth; + } } public override paint(cx: number, cy: number, canvas: ICanvas): void { diff --git a/packages/alphatab/src/rendering/glyphs/MultiBarRestGlyph.ts b/packages/alphatab/src/rendering/glyphs/MultiBarRestGlyph.ts index cd50b4a08..a7011d914 100644 --- a/packages/alphatab/src/rendering/glyphs/MultiBarRestGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/MultiBarRestGlyph.ts @@ -1,8 +1,8 @@ import { MusicFontSymbol } from '@coderline/alphatab/model/MusicFontSymbol'; import type { ICanvas } from '@coderline/alphatab/platform/ICanvas'; -import type { LineBarRenderer } from '@coderline/alphatab/rendering/LineBarRenderer'; import { Glyph } from '@coderline/alphatab/rendering/glyphs/Glyph'; import { NumberGlyph } from '@coderline/alphatab/rendering/glyphs/NumberGlyph'; +import type { LineBarRenderer } from '@coderline/alphatab/rendering/LineBarRenderer'; /** * @internal @@ -33,7 +33,6 @@ export class MultiBarRestGlyph extends Glyph { const numberGlyphTop = smufl.glyphTop.get(this._numberGlyph[0])!; this.renderer.registerOverflowTop(Math.abs(this._numberTop) + numberGlyphTop); - } public override paint(cx: number, cy: number, canvas: ICanvas): void { diff --git a/packages/alphatab/src/rendering/glyphs/MultiVoiceContainerGlyph.ts b/packages/alphatab/src/rendering/glyphs/MultiVoiceContainerGlyph.ts index ce893a60b..0a65d0dfa 100644 --- a/packages/alphatab/src/rendering/glyphs/MultiVoiceContainerGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/MultiVoiceContainerGlyph.ts @@ -10,6 +10,7 @@ import type { NoteXPosition, NoteYPosition } from '@coderline/alphatab/rendering import { BeatXPosition } from '@coderline/alphatab/rendering/BeatXPosition'; import type { BeatContainerGlyphBase } from '@coderline/alphatab/rendering/glyphs/BeatContainerGlyph'; import { Glyph } from '@coderline/alphatab/rendering/glyphs/Glyph'; +import { StaffSide } from '@coderline/alphatab/rendering/skyline/BarLocalSkyline'; import type { BarLayoutingInfo } from '@coderline/alphatab/rendering/staves/BarLayoutingInfo'; import type { BarBounds } from '@coderline/alphatab/rendering/utils/BarBounds'; import { ElementStyleHelper } from '@coderline/alphatab/rendering/utils/ElementStyleHelper'; @@ -53,12 +54,14 @@ export class MultiVoiceContainerGlyph extends Glyph { return y; } + /** Positions every beat container and emits its per-beat skyline contribution. */ public scaleToWidth(width: number): void { const force: number = this.renderer.layoutingInfo.spaceToForce(width); - this._scaleToForce(force); + this._scaleToForce(force, true); } - private _scaleToForce(force: number): void { + /** `emit=false`: positioning-only path; final skyline emission runs later via {@link scaleToWidth}. */ + private _scaleToForce(force: number, emit: boolean): void { this.width = this.renderer.layoutingInfo.calculateVoiceWidth(force); const positions = this.renderer.layoutingInfo.buildOnTimePositions(force); for (const beatGlyphs of this.beatGlyphs.values()) { @@ -130,18 +133,70 @@ export class MultiVoiceContainerGlyph extends Glyph { // size always previous glyph after we know the position // of the next glyph if (i > 0) { - const beatWidth: number = currentBeatGlyph.x - beatGlyphs[i - 1].x; - beatGlyphs[i - 1].scaleToWidth(beatWidth); + const previous = beatGlyphs[i - 1]; + const beatWidth: number = currentBeatGlyph.x - previous.x; + previous.scaleToWidth(beatWidth); + if (emit) { + this._emitBeatContainerSkyline(previous); + } } // for the last glyph size based on the full width if (i === j - 1) { const beatWidth: number = this.width - beatGlyphs[beatGlyphs.length - 1].x; currentBeatGlyph.scaleToWidth(beatWidth); + if (emit) { + this._emitBeatContainerSkyline(currentBeatGlyph); + } } } } } + private _emitBeatContainerSkyline(beatContainer: BeatContainerGlyphBase): void { + const renderer = this.renderer; + const rendererBottom = renderer.height; + const base = this.x + beatContainer.x; + const containerTop = beatContainer.getBoundingBoxTop(); + const containerBottom = beatContainer.getBoundingBoxBottom(); + const topOver = !Number.isNaN(containerTop) && containerTop < 0; + const botOver = !Number.isNaN(containerBottom) && containerBottom > rendererBottom; + + // Lazy getter hoisted once per beat; per-emit guards inlined to skip the wrapper. + const sky = renderer.barLocalSkyline; + + // Notehead extent (PreNotes..PostNotes), not slot width (which includes spring spacing). + if (topOver || botOver) { + const xStart = base + beatContainer.getBeatX(BeatXPosition.PreNotes, false); + const xEnd = base + beatContainer.getBeatX(BeatXPosition.PostNotes, false); + if (xEnd > xStart) { + if (topOver) { + sky.insertPlaced(StaffSide.Top, xStart, xEnd, containerTop * -1, 0); + } + if (botOver) { + sky.insertPlaced(StaffSide.Bottom, xStart, xEnd, containerBottom - rendererBottom, 0); + } + } + } + + const pending = beatContainer.pendingEffectOverflows; + if (pending.length > 0) { + const pendingXStart = base; + const pendingXEnd = base + beatContainer.width; + if (pendingXEnd > pendingXStart) { + for (const r of pending) { + if (r.minY < 0) { + sky.insertPlaced(StaffSide.Top, pendingXStart, pendingXEnd, r.minY * -1, 0); + } + if (r.maxY > rendererBottom) { + sky.insertPlaced(StaffSide.Bottom, pendingXStart, pendingXEnd, r.maxY - rendererBottom, 0); + } + } + } + } + + renderer.emitBeatSkyline(beatContainer); + } + public registerLayoutingInfo(info: BarLayoutingInfo): void { for (const beatGlyphs of this.beatGlyphs.values()) { for (const b of beatGlyphs) { @@ -155,8 +210,8 @@ export class MultiVoiceContainerGlyph extends Glyph { for (const b of beatGlyphs) { b.applyLayoutingInfo(info); } - this._scaleToForce(Math.max(this.renderer.settings.display.stretchForce, info.minStretchForce)); } + this._scaleToForce(Math.max(this.renderer.settings.display.stretchForce, info.minStretchForce), false); } public addGlyph(bg: BeatContainerGlyphBase): void { @@ -269,6 +324,7 @@ export class MultiVoiceContainerGlyph extends Glyph { for (const v of this.beatGlyphs.values()) { let x = 0; for (const b of v) { + b.prepareForOverflowPass(); b.x = x; b.doLayout(); x += b.width; diff --git a/packages/alphatab/src/rendering/glyphs/MusicFontGlyph.ts b/packages/alphatab/src/rendering/glyphs/MusicFontGlyph.ts index 75b3e7909..f5d158915 100644 --- a/packages/alphatab/src/rendering/glyphs/MusicFontGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/MusicFontGlyph.ts @@ -1,7 +1,7 @@ +import type { Color } from '@coderline/alphatab/model/Color'; +import type { MusicFontSymbol } from '@coderline/alphatab/model/MusicFontSymbol'; import type { ICanvas } from '@coderline/alphatab/platform/ICanvas'; import { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; -import type { MusicFontSymbol } from '@coderline/alphatab/model/MusicFontSymbol'; -import type { Color } from '@coderline/alphatab/model/Color'; /** * @internal @@ -20,6 +20,22 @@ export class MusicFontGlyph extends EffectGlyph { this.symbol = symbol; } + public override getBoundingBoxLeft(): number { + let x = super.getBoundingBoxLeft() + this.offsetX; + if (this.center) { + x -= this.width / 2; + } + return x; + } + + public override getBoundingBoxRight(): number { + let x = super.getBoundingBoxRight() + this.offsetX; + if (this.center) { + x -= this.width / 2; + } + return x; + } + public override getBoundingBoxTop(): number { const bBoxTop = this.renderer.smuflMetrics.glyphTop.get(this.symbol)!; return this.y - this.offsetY - bBoxTop; diff --git a/packages/alphatab/src/rendering/glyphs/OttavaGlyph.ts b/packages/alphatab/src/rendering/glyphs/OttavaGlyph.ts index 9a4c83409..3d76a35a3 100644 --- a/packages/alphatab/src/rendering/glyphs/OttavaGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/OttavaGlyph.ts @@ -1,8 +1,8 @@ +import { MusicFontSymbol } from '@coderline/alphatab/model/MusicFontSymbol'; import { Ottavia } from '@coderline/alphatab/model/Ottavia'; import { CanvasHelper, type ICanvas } from '@coderline/alphatab/platform/ICanvas'; import { BeatXPosition } from '@coderline/alphatab/rendering/BeatXPosition'; import { GroupedEffectGlyph } from '@coderline/alphatab/rendering/glyphs/GroupedEffectGlyph'; -import { MusicFontSymbol } from '@coderline/alphatab/model/MusicFontSymbol'; /** * @internal @@ -10,6 +10,7 @@ import { MusicFontSymbol } from '@coderline/alphatab/model/MusicFontSymbol'; export class OttavaGlyph extends GroupedEffectGlyph { private _ottava: Ottavia; private _aboveStaff: boolean; + private _symbolWidth: number = 0; public constructor(ottava: Ottavia, aboveStaff: boolean) { super(BeatXPosition.PostNotes); @@ -20,6 +21,30 @@ export class OttavaGlyph extends GroupedEffectGlyph { public override doLayout(): void { super.doLayout(); this.height = this.renderer.smuflMetrics.glyphHeights.get(MusicFontSymbol.QuindicesimaAlta)!; + this._symbolWidth = OttavaGlyph._resolveSymbolWidth(this._ottava, this.renderer.smuflMetrics.glyphWidths); + } + + public override getBoundingBoxLeft(): number { + return this.x - this._symbolWidth / 2; + } + + private static _resolveSymbolWidth(ottava: Ottavia, glyphWidths: Map): number { + switch (ottava) { + case Ottavia._15ma: + return glyphWidths.get(MusicFontSymbol.QuindicesimaAlta) ?? 0; + case Ottavia._8va: + return glyphWidths.get(MusicFontSymbol.OttavaAlta) ?? 0; + case Ottavia._8vb: + return glyphWidths.get(MusicFontSymbol.OttavaBassaVb) ?? 0; + case Ottavia._15mb: + return ( + (glyphWidths.get(MusicFontSymbol.Quindicesima) ?? 0) + + (glyphWidths.get(MusicFontSymbol.OctaveBaselineM) ?? 0) + + (glyphWidths.get(MusicFontSymbol.OctaveBaselineB) ?? 0) + ); + default: + return 0; + } } protected override paintNonGrouped(cx: number, cy: number, canvas: ICanvas): void { @@ -31,7 +56,8 @@ export class OttavaGlyph extends GroupedEffectGlyph { switch (this._ottava) { case Ottavia._15ma: size = this.renderer.smuflMetrics.glyphWidths.get(MusicFontSymbol.QuindicesimaAlta)!; - CanvasHelper.fillMusicFontSymbolSafe(canvas, + CanvasHelper.fillMusicFontSymbolSafe( + canvas, cx + this.x - size / 2, cy + this.y + this.height, 1, @@ -41,7 +67,8 @@ export class OttavaGlyph extends GroupedEffectGlyph { break; case Ottavia._8va: size = this.renderer.smuflMetrics.glyphWidths.get(MusicFontSymbol.OttavaAlta)!; - CanvasHelper.fillMusicFontSymbolSafe(canvas, + CanvasHelper.fillMusicFontSymbolSafe( + canvas, cx + this.x - size / 2, cy + this.y + this.height, 1, @@ -51,7 +78,8 @@ export class OttavaGlyph extends GroupedEffectGlyph { break; case Ottavia._8vb: size = this.renderer.smuflMetrics.glyphWidths.get(MusicFontSymbol.OttavaBassaVb)!; - CanvasHelper.fillMusicFontSymbolSafe(canvas, + CanvasHelper.fillMusicFontSymbolSafe( + canvas, cx + this.x - size / 2, cy + this.y + this.height, 1, @@ -67,7 +95,8 @@ export class OttavaGlyph extends GroupedEffectGlyph { 1; // NOTE: SMUFL does not have a glyph for 15mb so we build it - CanvasHelper.fillMusicFontSymbolsSafe(canvas, + CanvasHelper.fillMusicFontSymbolsSafe( + canvas, cx + this.x - size / 2, cy + this.y + this.height, 1, diff --git a/packages/alphatab/src/rendering/glyphs/RepeatCountGlyph.ts b/packages/alphatab/src/rendering/glyphs/RepeatCountGlyph.ts index eff8c43e9..d678f6943 100644 --- a/packages/alphatab/src/rendering/glyphs/RepeatCountGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/RepeatCountGlyph.ts @@ -1,30 +1,42 @@ +import { NotationElement } from '@coderline/alphatab/NotationSettings'; import { type ICanvas, TextAlign } from '@coderline/alphatab/platform/ICanvas'; -import { Glyph } from '@coderline/alphatab/rendering/glyphs/Glyph'; import type { RenderingResources } from '@coderline/alphatab/RenderingResources'; +import { Glyph } from '@coderline/alphatab/rendering/glyphs/Glyph'; import type { LineBarRenderer } from '@coderline/alphatab/rendering/LineBarRenderer'; import { ElementStyleHelper } from '@coderline/alphatab/rendering/utils/ElementStyleHelper'; -import { NotationElement } from '@coderline/alphatab/NotationSettings'; /** * @internal */ export class RepeatCountGlyph extends Glyph { private _count: number = 0; + private _text: string = ''; + private _textWidth: number = 0; + private static readonly _rightEdgeOffsetFactor: number = 2 / 3; public constructor(x: number, y: number, count: number) { super(x, y); - this._count = 0; this._count = count; } public override doLayout(): void { + this._text = `x${this._count}`; this.renderer.scoreRenderer.canvas!.font = this.renderer.resources.elementFonts.get( NotationElement.RepeatCount )!; - const size = this.renderer.scoreRenderer.canvas!.measureText(`x${this._count}`); + const size = this.renderer.scoreRenderer.canvas!.measureText(this._text); this.width = 0; // do not account width this.height = size.height; this.y -= size.height; + this._textWidth = size.width; + } + + public override getBoundingBoxLeft(): number { + return this.x - this._textWidth * (1 + RepeatCountGlyph._rightEdgeOffsetFactor); + } + + public override getBoundingBoxRight(): number { + return this.x - this._textWidth * RepeatCountGlyph._rightEdgeOffsetFactor; } public override paint(cx: number, cy: number, canvas: ICanvas): void { @@ -38,9 +50,8 @@ export class RepeatCountGlyph extends Glyph { const oldAlign: TextAlign = canvas.textAlign; canvas.font = res.elementFonts.get(NotationElement.RepeatCount)!; canvas.textAlign = TextAlign.Right; - const s: string = `x${this._count}`; - const w: number = canvas.measureText(s).width / 1.5; - canvas.fillText(s, cx + this.x - w, cy + this.y); + const rightEdgeOffset = this._textWidth * RepeatCountGlyph._rightEdgeOffsetFactor; + canvas.fillText(this._text, cx + this.x - rightEdgeOffset, cy + this.y); canvas.textAlign = oldAlign; } } diff --git a/packages/alphatab/src/rendering/glyphs/ScoreBendGlyph.ts b/packages/alphatab/src/rendering/glyphs/ScoreBendGlyph.ts index 2fcb6d874..2f83fe795 100644 --- a/packages/alphatab/src/rendering/glyphs/ScoreBendGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/ScoreBendGlyph.ts @@ -49,6 +49,15 @@ export class ScoreBendGlyph extends ScoreHelperNotesBaseGlyph implements ITieGly return super.getBoundingBoxBottom() + this._calculateMaxSlurHeight(BeamDirection.Down); } + /** Staff-local x ({@link ITieGlyph} convention); spans the start beat. */ + public override getBoundingBoxLeft(): number { + return this.renderer.x + this.renderer.getBeatX(this._beat, BeatXPosition.PostNotes); + } + + public override getBoundingBoxRight(): number { + return this.renderer.x + this.renderer.getBeatX(this._beat, BeatXPosition.EndBeat); + } + public doMultiVoiceLayout(): void { this._middleNoteGlyph?.doMultiVoiceLayout(); this._endNoteGlyph?.doMultiVoiceLayout(); diff --git a/packages/alphatab/src/rendering/glyphs/ScoreLegatoGlyph.ts b/packages/alphatab/src/rendering/glyphs/ScoreLegatoGlyph.ts index a80a5ce0e..2c45813fa 100644 --- a/packages/alphatab/src/rendering/glyphs/ScoreLegatoGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/ScoreLegatoGlyph.ts @@ -73,18 +73,18 @@ export class ScoreLegatoGlyph extends TieGlyph { if (this.startBeat!.isRest) { switch (this.tieDirection) { case BeamDirection.Up: - return startBeatRenderer.y + startBeatRenderer.getRestY(this.startBeat, NoteYPosition.Top); + return startBeatRenderer.getRestY(this.startBeat, NoteYPosition.Top); default: - return startBeatRenderer.y + startBeatRenderer.getRestY(this.startBeat, NoteYPosition.Bottom); + return startBeatRenderer.getRestY(this.startBeat, NoteYPosition.Bottom); } } switch (this.tieDirection) { case BeamDirection.Up: // below lowest note - return startBeatRenderer.y + startBeatRenderer.getNoteY(this.startBeat!.maxNote!, NoteYPosition.Top); + return startBeatRenderer.getNoteY(this.startBeat!.maxNote!, NoteYPosition.Top); default: - return startBeatRenderer.y + startBeatRenderer.getNoteY(this.startBeat!.minNote!, NoteYPosition.Bottom); + return startBeatRenderer.getNoteY(this.startBeat!.minNote!, NoteYPosition.Bottom); } } @@ -114,9 +114,9 @@ export class ScoreLegatoGlyph extends TieGlyph { if (this.endBeat.isRest) { switch (this.tieDirection) { case BeamDirection.Up: - return endBeatRenderer.y + endBeatRenderer.getRestY(this.endBeat, NoteYPosition.Top); + return endBeatRenderer.getRestY(this.endBeat, NoteYPosition.Top); default: - return endBeatRenderer.y + endBeatRenderer.getRestY(this.endBeat, NoteYPosition.Bottom); + return endBeatRenderer.getRestY(this.endBeat, NoteYPosition.Bottom); } } @@ -128,40 +128,29 @@ export class ScoreLegatoGlyph extends TieGlyph { switch (this.tieDirection) { case BeamDirection.Up: // stem upper end - return ( - endBeatRenderer.y + - endBeatRenderer.getNoteY(this.endBeat!.maxNote!, NoteYPosition.TopWithStem) - ); + return endBeatRenderer.getNoteY(this.endBeat!.maxNote!, NoteYPosition.TopWithStem); default: // stem lower end - return ( - endBeatRenderer.y + - endBeatRenderer.getNoteY(this.endBeat!.minNote!, NoteYPosition.BottomWithStem) - ); + return endBeatRenderer.getNoteY(this.endBeat!.minNote!, NoteYPosition.BottomWithStem); } } switch (this.tieDirection) { case BeamDirection.Up: // stem upper end - return ( - endBeatRenderer.y + - endBeatRenderer.getNoteY(this.endBeat!.maxNote!, NoteYPosition.BottomWithStem) - ); + return endBeatRenderer.getNoteY(this.endBeat!.maxNote!, NoteYPosition.BottomWithStem); default: // stem lower end - return ( - endBeatRenderer.y + endBeatRenderer.getNoteY(this.endBeat!.minNote!, NoteYPosition.TopWithStem) - ); + return endBeatRenderer.getNoteY(this.endBeat!.minNote!, NoteYPosition.TopWithStem); } } switch (this.tieDirection) { case BeamDirection.Up: // below lowest note - return endBeatRenderer.y + endBeatRenderer.getNoteY(this.endBeat!.maxNote!, NoteYPosition.Top); + return endBeatRenderer.getNoteY(this.endBeat!.maxNote!, NoteYPosition.Top); default: // above highest note - return endBeatRenderer.y + endBeatRenderer.getNoteY(this.endBeat!.minNote!, NoteYPosition.Bottom); + return endBeatRenderer.getNoteY(this.endBeat!.minNote!, NoteYPosition.Bottom); } } } diff --git a/packages/alphatab/src/rendering/glyphs/ScoreNoteChordGlyph.ts b/packages/alphatab/src/rendering/glyphs/ScoreNoteChordGlyph.ts index 90de9f894..05c336ab0 100644 --- a/packages/alphatab/src/rendering/glyphs/ScoreNoteChordGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/ScoreNoteChordGlyph.ts @@ -240,7 +240,7 @@ export class ScoreNoteChordGlyph extends ScoreNoteChordGlyphBase { } if (minEffectY !== null) { - scoreRenderer.registerBeatEffectOverflows(minEffectY, maxEffectY ?? 0); + scoreRenderer.registerBeatEffectOverflowsForBeat(this.beat, minEffectY, maxEffectY ?? 0); } if (this.beat.isTremolo && !this.beat.deadSlapped) { diff --git a/packages/alphatab/src/rendering/glyphs/ScoreSlideLineGlyph.ts b/packages/alphatab/src/rendering/glyphs/ScoreSlideLineGlyph.ts index 6dffdac1f..8c9360388 100644 --- a/packages/alphatab/src/rendering/glyphs/ScoreSlideLineGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/ScoreSlideLineGlyph.ts @@ -13,6 +13,20 @@ import type { ITieGlyph } from '@coderline/alphatab/rendering/glyphs/TieGlyph'; import type { ScoreBarRenderer } from '@coderline/alphatab/rendering/ScoreBarRenderer'; import type { ScoreBeatContainerGlyph } from '@coderline/alphatab/rendering/ScoreBeatContainerGlyph'; +/** + * Staff-absolute coordinates (include the note's renderer `.x`/`.y`). + * + * @record + * @internal + */ +interface ScoreSlideSegment { + startX: number; + startY: number; + endX: number; + endY: number; + waves: boolean; +} + /** * @internal */ @@ -22,6 +36,14 @@ export class ScoreSlideLineGlyph extends Glyph implements ITieGlyph { private _startNote: Note; private _parent: BeatContainerGlyph; + // Per-cycle cache; invalidated in doLayout. Paired *CacheValid flags + // rather than nullable-as-sentinel — C# transpile doesn't unwrap `T | null` + // with `!` cleanly. + private _slideInCache: ScoreSlideSegment | null = null; + private _slideInCacheValid: boolean = false; + private _slideOutCache: ScoreSlideSegment | null = null; + private _slideOutCacheValid: boolean = false; + // the slide line cannot overflow anything and there are ties drawn in here public readonly checkForOverflow = false; @@ -34,25 +56,97 @@ export class ScoreSlideLineGlyph extends Glyph implements ITieGlyph { } public override doLayout(): void { + this._slideInCacheValid = false; + this._slideInCache = null; + this._slideOutCacheValid = false; + this._slideOutCache = null; this.width = 0; } + /** Lazy — geometry depends on `renderer.x`, final only post-system-layout. */ + public override getBoundingBoxLeft(): number { + let min = 0; + let found = false; + const slideIn = this._computeSlideIn(); + if (slideIn) { + min = Math.min(slideIn.startX, slideIn.endX); + found = true; + } + const slideOut = this._computeSlideOut(); + if (slideOut) { + const localMin = Math.min(slideOut.startX, slideOut.endX); + if (!found || localMin < min) { + min = localMin; + found = true; + } + } + return found ? min : this.x; + } + + public override getBoundingBoxRight(): number { + let max = 0; + let found = false; + const slideIn = this._computeSlideIn(); + if (slideIn) { + max = Math.max(slideIn.startX, slideIn.endX); + found = true; + } + const slideOut = this._computeSlideOut(); + if (slideOut) { + const localMax = Math.max(slideOut.startX, slideOut.endX); + if (!found || localMax > max) { + max = localMax; + found = true; + } + } + return found ? max : this.x; + } + public override paint(cx: number, cy: number, canvas: ICanvas): void { - this._paintSlideIn(cx, cy, canvas); - this._drawSlideOut(cx, cy, canvas); + const slideIn = this._computeSlideIn(); + if (slideIn) { + this._paintSlideLine( + canvas, + false, + cx + slideIn.startX, + cx + slideIn.endX, + cy + slideIn.startY, + cy + slideIn.endY + ); + } + const slideOut = this._computeSlideOut(); + if (slideOut) { + this._paintSlideLine( + canvas, + slideOut.waves, + cx + slideOut.startX, + cx + slideOut.endX, + cy + slideOut.startY, + cy + slideOut.endY + ); + } + } + + private _computeSlideIn(): ScoreSlideSegment | null { + if (this._slideInCacheValid) { + return this._slideInCache; + } + const result = this._computeSlideInUncached(); + this._slideInCache = result; + this._slideInCacheValid = true; + return result; } - private _paintSlideIn(cx: number, cy: number, canvas: ICanvas): void { + private _computeSlideInUncached(): ScoreSlideSegment | null { const startNoteRenderer: ScoreBarRenderer = this.renderer as ScoreBarRenderer; const sizeX: number = startNoteRenderer.smuflMetrics.simpleSlideWidth; let endX = - cx + startNoteRenderer.x + startNoteRenderer.getNoteX(this._startNote, NoteXPosition.Left) - startNoteRenderer.smuflMetrics.preNoteEffectPadding; - const endY = cy + startNoteRenderer.y + startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Center); + const endY = startNoteRenderer.y + startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Center); let startX = endX - sizeX; - let startY: number = cy + startNoteRenderer.y; + let startY: number = startNoteRenderer.y; switch (this._inType) { case SlideInType.IntoFromBelow: @@ -62,13 +156,13 @@ export class ScoreSlideLineGlyph extends Glyph implements ITieGlyph { startY += startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Top); break; default: - return; + return null; } const accidentalsWidth: number = this._getAccidentalsWidth(startNoteRenderer, this._startNote.beat); startX -= accidentalsWidth; endX -= accidentalsWidth; - this._paintSlideLine(canvas, false, startX, endX, startY, endY); + return { startX, startY, endX, endY, waves: false }; } private _getAccidentalsWidth(renderer: ScoreBarRenderer, beat: Beat): number { @@ -76,7 +170,17 @@ export class ScoreSlideLineGlyph extends Glyph implements ITieGlyph { return container.accidentalsWidth; } - private _drawSlideOut(cx: number, cy: number, canvas: ICanvas): void { + private _computeSlideOut(): ScoreSlideSegment | null { + if (this._slideOutCacheValid) { + return this._slideOutCache; + } + const result = this._computeSlideOutUncached(); + this._slideOutCache = result; + this._slideOutCacheValid = true; + return result; + } + + private _computeSlideOutUncached(): ScoreSlideSegment | null { const startNoteRenderer: ScoreBarRenderer = this.renderer as ScoreBarRenderer; const sizeX: number = startNoteRenderer.smuflMetrics.simpleSlideWidth; const offsetX: number = startNoteRenderer.smuflMetrics.postNoteEffectPadding; @@ -89,11 +193,10 @@ export class ScoreSlideLineGlyph extends Glyph implements ITieGlyph { case SlideOutType.Shift: case SlideOutType.Legato: startX = - cx + startNoteRenderer.x + startNoteRenderer.getBeatX(this._startNote.beat, BeatXPosition.PostNotes) + offsetX; - startY = cy + startNoteRenderer.y + startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Center); + startY = startNoteRenderer.y + startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Center); if (this._startNote.slideTarget) { const endNoteRenderer: BarRendererBase | null = this.renderer.scoreRenderer.layout!.getRendererForBar( @@ -101,69 +204,54 @@ export class ScoreSlideLineGlyph extends Glyph implements ITieGlyph { this._startNote.slideTarget.beat.voice.bar ); if (!endNoteRenderer || endNoteRenderer.staff !== startNoteRenderer.staff) { - endX = cx + startNoteRenderer.x + startNoteRenderer.width; + endX = startNoteRenderer.x + startNoteRenderer.width; if (this._startNote.slideTarget.realValue > this._startNote.realValue) { - endY = - cy + - startNoteRenderer.y + - startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Top); + endY = startNoteRenderer.y + startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Top); } else { endY = - cy + - startNoteRenderer.y + - startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Bottom); + startNoteRenderer.y + startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Bottom); } } else { endX = - cx + endNoteRenderer.x + endNoteRenderer.getBeatX(this._startNote.slideTarget.beat, BeatXPosition.PreNotes) - offsetX; endY = - cy + endNoteRenderer.y + endNoteRenderer.getNoteY(this._startNote.slideTarget, NoteYPosition.Center); } } else { - endX = cx + startNoteRenderer.x + this._parent.x; + endX = startNoteRenderer.x + this._parent.x; endY = startY; } break; case SlideOutType.OutUp: startX = - cx + - startNoteRenderer.x + - startNoteRenderer.getNoteX(this._startNote, NoteXPosition.Right) + - offsetX; - startY = cy + startNoteRenderer.y + startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Center); + startNoteRenderer.x + startNoteRenderer.getNoteX(this._startNote, NoteXPosition.Right) + offsetX; + startY = startNoteRenderer.y + startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Center); endX = startX + sizeX; - endY = cy + startNoteRenderer.y + startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Top); + endY = startNoteRenderer.y + startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Top); break; case SlideOutType.OutDown: startX = - cx + - startNoteRenderer.x + - startNoteRenderer.getNoteX(this._startNote, NoteXPosition.Right) + - offsetX; - startY = cy + startNoteRenderer.y + startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Center); + startNoteRenderer.x + startNoteRenderer.getNoteX(this._startNote, NoteXPosition.Right) + offsetX; + startY = startNoteRenderer.y + startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Center); endX = startX + sizeX; - endY = cy + startNoteRenderer.y + startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Bottom); + endY = startNoteRenderer.y + startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Bottom); break; case SlideOutType.PickSlideUp: startX = - cx + startNoteRenderer.x + startNoteRenderer.getNoteX(this._startNote, NoteXPosition.Right) + offsetX * 2; - startY = cy + startNoteRenderer.y + startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Center); - endY = cy + startNoteRenderer.y + startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Top); - endX = cx + startNoteRenderer.x + startNoteRenderer.width; + startY = startNoteRenderer.y + startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Center); + endY = startNoteRenderer.y + startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Top); + endX = startNoteRenderer.x + startNoteRenderer.width; if ( this._startNote.beat.nextBeat && this._startNote.beat.nextBeat.voice === this._startNote.beat.voice ) { endX = - cx + startNoteRenderer.x + startNoteRenderer.getBeatX(this._startNote.beat.nextBeat, BeatXPosition.PreNotes); } @@ -171,28 +259,26 @@ export class ScoreSlideLineGlyph extends Glyph implements ITieGlyph { break; case SlideOutType.PickSlideDown: startX = - cx + startNoteRenderer.x + startNoteRenderer.getNoteX(this._startNote, NoteXPosition.Right) + offsetX * 2; - startY = cy + startNoteRenderer.y + startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Center); - endY = cy + startNoteRenderer.y + startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Bottom); - endX = cx + startNoteRenderer.x + startNoteRenderer.width; + startY = startNoteRenderer.y + startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Center); + endY = startNoteRenderer.y + startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Bottom); + endX = startNoteRenderer.x + startNoteRenderer.width; if ( this._startNote.beat.nextBeat && this._startNote.beat.nextBeat.voice === this._startNote.beat.voice ) { endX = - cx + startNoteRenderer.x + startNoteRenderer.getBeatX(this._startNote.beat.nextBeat, BeatXPosition.PreNotes); } waves = true; break; default: - return; + return null; } - this._paintSlideLine(canvas, waves, startX, endX, startY, endY); + return { startX, startY, endX, endY, waves }; } private _paintSlideLine( diff --git a/packages/alphatab/src/rendering/glyphs/ScoreSlurGlyph.ts b/packages/alphatab/src/rendering/glyphs/ScoreSlurGlyph.ts index 4e09d2e11..6d0647c51 100644 --- a/packages/alphatab/src/rendering/glyphs/ScoreSlurGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/ScoreSlurGlyph.ts @@ -2,7 +2,7 @@ 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 { type TieGlyphLabel, TieGlyphLabels } from '@coderline/alphatab/rendering/glyphs/TieGlyphLabel'; import { BeamDirection } from '@coderline/alphatab/rendering/utils/BeamDirection'; /** @@ -45,13 +45,13 @@ export class ScoreSlurGlyph extends ScoreTieGlyph { if (this._isStartCentered()) { switch (this.tieDirection) { case BeamDirection.Up: - return this.renderer.y + this.renderer!.getNoteY(this.startNote, NoteYPosition.Top); + return this.renderer!.getNoteY(this.startNote, NoteYPosition.Top); default: - return this.renderer.y + this.renderer!.getNoteY(this.startNote, NoteYPosition.Bottom); + return this.renderer!.getNoteY(this.startNote, NoteYPosition.Bottom); } } - return this.renderer.y + this.renderer!.getNoteY(this.startNote, NoteYPosition.Center); + return this.renderer!.getNoteY(this.startNote, NoteYPosition.Center); } protected override calculateEndX(): number { @@ -79,19 +79,19 @@ export class ScoreSlurGlyph extends ScoreTieGlyph { if (this._isEndOnStem()) { switch (this.tieDirection) { case BeamDirection.Up: - return endNoteRenderer.y + endNoteRenderer.getNoteY(this.endNote, NoteYPosition.TopWithStem); + return endNoteRenderer.getNoteY(this.endNote, NoteYPosition.TopWithStem); default: - return endNoteRenderer.y + endNoteRenderer.getNoteY(this.endNote, NoteYPosition.BottomWithStem); + return endNoteRenderer.getNoteY(this.endNote, NoteYPosition.BottomWithStem); } } switch (this.tieDirection) { case BeamDirection.Up: - return endNoteRenderer.y + endNoteRenderer.getNoteY(this.endNote, NoteYPosition.Top); + return endNoteRenderer.getNoteY(this.endNote, NoteYPosition.Top); default: - return endNoteRenderer.y + endNoteRenderer.getNoteY(this.endNote, NoteYPosition.Bottom); + return endNoteRenderer.getNoteY(this.endNote, NoteYPosition.Bottom); } } - return endNoteRenderer.y + endNoteRenderer.getNoteY(this.endNote, NoteYPosition.Center); + return endNoteRenderer.getNoteY(this.endNote, NoteYPosition.Center); } private _isStartCentered() { diff --git a/packages/alphatab/src/rendering/glyphs/SlashNoteHeadGlyph.ts b/packages/alphatab/src/rendering/glyphs/SlashNoteHeadGlyph.ts index c881bb149..e2eb54c0f 100644 --- a/packages/alphatab/src/rendering/glyphs/SlashNoteHeadGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/SlashNoteHeadGlyph.ts @@ -66,7 +66,7 @@ export class SlashNoteHeadGlyph extends NoteHeadGlyphBase { } if (!Number.isNaN(minEffectY)) { - lr.registerBeatEffectOverflows(minEffectY, maxEffectY); + lr.registerBeatEffectOverflowsForBeat(this.beat!, minEffectY, maxEffectY); } const direction = lr.getBeatDirection(this.beat!); diff --git a/packages/alphatab/src/rendering/glyphs/TabBendGlyph.ts b/packages/alphatab/src/rendering/glyphs/TabBendGlyph.ts index 67471154d..69be69bb9 100644 --- a/packages/alphatab/src/rendering/glyphs/TabBendGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/TabBendGlyph.ts @@ -7,7 +7,7 @@ import type { Font } from '@coderline/alphatab/model/Font'; import { MusicFontSymbol } from '@coderline/alphatab/model/MusicFontSymbol'; import type { Note } from '@coderline/alphatab/model/Note'; import { VibratoType } from '@coderline/alphatab/model/VibratoType'; -import { TextBaseline, type ICanvas } from '@coderline/alphatab/platform/ICanvas'; +import { type ICanvas, TextBaseline } from '@coderline/alphatab/platform/ICanvas'; import { type BarRendererBase, NoteXPosition, NoteYPosition } from '@coderline/alphatab/rendering/BarRendererBase'; import { BeatXPosition } from '@coderline/alphatab/rendering/BeatXPosition'; import { Glyph } from '@coderline/alphatab/rendering/glyphs/Glyph'; @@ -36,6 +36,23 @@ export class TabBendGlyph extends Glyph implements ITieGlyph { super(0, 0); } + /** Staff-local x ({@link ITieGlyph} convention); spans the first bending beat. */ + public override getBoundingBoxLeft(): number { + if (this._notes.length === 0) { + return this.x; + } + const beat = this._notes[0].beat; + return this.renderer.x + this.renderer.getBeatX(beat, BeatXPosition.PostNotes); + } + + public override getBoundingBoxRight(): number { + if (this._notes.length === 0) { + return this.x; + } + const beat = this._notes[0].beat; + return this.renderer.x + this.renderer.getBeatX(beat, BeatXPosition.EndBeat); + } + public addBends(note: Note): void { this._notes.push(note); const renderPoints: TabBendRenderPoint[] = this._createRenderingPoints(note); diff --git a/packages/alphatab/src/rendering/glyphs/TabNoteChordGlyph.ts b/packages/alphatab/src/rendering/glyphs/TabNoteChordGlyph.ts index 1e9b03d90..43288a895 100644 --- a/packages/alphatab/src/rendering/glyphs/TabNoteChordGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/TabNoteChordGlyph.ts @@ -2,6 +2,7 @@ import { type Beat, BeatSubElement } from '@coderline/alphatab/model/Beat'; import { Duration } from '@coderline/alphatab/model/Duration'; import type { Note } from '@coderline/alphatab/model/Note'; import { type ICanvas, TextBaseline } from '@coderline/alphatab/platform/ICanvas'; +import type { RenderingResources } from '@coderline/alphatab/RenderingResources'; import { NoteXPosition, NoteYPosition } from '@coderline/alphatab/rendering/BarRendererBase'; import { DeadSlappedBeatGlyph } from '@coderline/alphatab/rendering/glyphs/DeadSlappedBeatGlyph'; import { Glyph } from '@coderline/alphatab/rendering/glyphs/Glyph'; @@ -9,7 +10,6 @@ import type { NoteNumberGlyph } from '@coderline/alphatab/rendering/glyphs/NoteN import { TremoloPickingGlyph } from '@coderline/alphatab/rendering/glyphs/TremoloPickingGlyph'; import type { BeatBounds } from '@coderline/alphatab/rendering/utils/BeatBounds'; import { ElementStyleHelper } from '@coderline/alphatab/rendering/utils/ElementStyleHelper'; -import type { RenderingResources } from '@coderline/alphatab/RenderingResources'; /** * @internal @@ -161,7 +161,7 @@ export class TabNoteChordGlyph extends Glyph { } if (!Number.isNaN(minEffectY)) { - this.renderer.registerBeatEffectOverflows(minEffectY, maxEffectY); + this.renderer.registerBeatEffectOverflowsForBeat(this.beat, minEffectY, maxEffectY); } } diff --git a/packages/alphatab/src/rendering/glyphs/TabSlideLineGlyph.ts b/packages/alphatab/src/rendering/glyphs/TabSlideLineGlyph.ts index 53c4011f0..64f6acf53 100644 --- a/packages/alphatab/src/rendering/glyphs/TabSlideLineGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/TabSlideLineGlyph.ts @@ -3,7 +3,7 @@ import { SlideInType } from '@coderline/alphatab/model/SlideInType'; import { SlideOutType } from '@coderline/alphatab/model/SlideOutType'; import { VibratoType } from '@coderline/alphatab/model/VibratoType'; import type { ICanvas } from '@coderline/alphatab/platform/ICanvas'; -import { type BarRendererBase, NoteYPosition, NoteXPosition } from '@coderline/alphatab/rendering/BarRendererBase'; +import { type BarRendererBase, NoteXPosition, NoteYPosition } from '@coderline/alphatab/rendering/BarRendererBase'; import { BeatXPosition } from '@coderline/alphatab/rendering/BeatXPosition'; import type { BeatContainerGlyph } from '@coderline/alphatab/rendering/glyphs/BeatContainerGlyph'; import { Glyph } from '@coderline/alphatab/rendering/glyphs/Glyph'; @@ -11,6 +11,20 @@ import { NoteVibratoGlyph } from '@coderline/alphatab/rendering/glyphs/NoteVibra import type { ITieGlyph } from '@coderline/alphatab/rendering/glyphs/TieGlyph'; import type { TabBarRenderer } from '@coderline/alphatab/rendering/TabBarRenderer'; +/** + * Staff-absolute coordinates. See {@link ScoreSlideLineGlyph}. + * + * @record + * @internal + */ +interface SlideSegment { + startX: number; + startY: number; + endX: number; + endY: number; + waves: boolean; +} + /** * @internal */ @@ -20,6 +34,14 @@ export class TabSlideLineGlyph extends Glyph implements ITieGlyph { private _startNote: Note; private _parent: BeatContainerGlyph; + // Per-cycle cache; invalidated in doLayout. Paired *CacheValid flags + // rather than nullable-as-sentinel — C# transpile doesn't unwrap + // `T | null` with `!` cleanly. See {@link ScoreSlideLineGlyph}. + private _slideInCache: SlideSegment | null = null; + private _slideInCacheValid: boolean = false; + private _slideOutCache: SlideSegment | null = null; + private _slideOutCacheValid: boolean = false; + // the slide line cannot overflow anything and there are ties drawn in here public readonly checkForOverflow = false; @@ -32,15 +54,87 @@ export class TabSlideLineGlyph extends Glyph implements ITieGlyph { } public override doLayout(): void { + this._slideInCacheValid = false; + this._slideInCache = null; + this._slideOutCacheValid = false; + this._slideOutCache = null; this.width = 0; } + public override getBoundingBoxLeft(): number { + let min = 0; + let found = false; + const slideIn = this._computeSlideIn(); + if (slideIn) { + min = Math.min(slideIn.startX, slideIn.endX); + found = true; + } + const slideOut = this._computeSlideOut(); + if (slideOut) { + const localMin = Math.min(slideOut.startX, slideOut.endX); + if (!found || localMin < min) { + min = localMin; + found = true; + } + } + return found ? min : this.x; + } + + public override getBoundingBoxRight(): number { + let max = 0; + let found = false; + const slideIn = this._computeSlideIn(); + if (slideIn) { + max = Math.max(slideIn.startX, slideIn.endX); + found = true; + } + const slideOut = this._computeSlideOut(); + if (slideOut) { + const localMax = Math.max(slideOut.startX, slideOut.endX); + if (!found || localMax > max) { + max = localMax; + found = true; + } + } + return found ? max : this.x; + } + public override paint(cx: number, cy: number, canvas: ICanvas): void { - this._paintSlideIn(cx, cy, canvas); - this._paintSlideOut(cx, cy, canvas); + const slideIn = this._computeSlideIn(); + if (slideIn) { + this._paintSlideLine( + canvas, + false, + cx + slideIn.startX, + cx + slideIn.endX, + cy + slideIn.startY, + cy + slideIn.endY + ); + } + const slideOut = this._computeSlideOut(); + if (slideOut) { + this._paintSlideLine( + canvas, + slideOut.waves, + cx + slideOut.startX, + cx + slideOut.endX, + cy + slideOut.startY, + cy + slideOut.endY + ); + } } - private _paintSlideIn(cx: number, cy: number, canvas: ICanvas): void { + private _computeSlideIn(): SlideSegment | null { + if (this._slideInCacheValid) { + return this._slideInCache; + } + const result = this._computeSlideInUncached(); + this._slideInCache = result; + this._slideInCacheValid = true; + return result; + } + + private _computeSlideInUncached(): SlideSegment | null { const startNoteRenderer: TabBarRenderer = this.renderer as TabBarRenderer; const sizeX: number = this.renderer.smuflMetrics.simpleSlideWidth; const sizeY: number = this.renderer.smuflMetrics.simpleSlideHeight; @@ -51,48 +145,36 @@ export class TabSlideLineGlyph extends Glyph implements ITieGlyph { const offsetX = this.renderer.smuflMetrics.preNoteEffectPadding; switch (this._inType) { case SlideInType.IntoFromBelow: - endX = - cx + - startNoteRenderer.x + - startNoteRenderer.getNoteX(this._startNote, NoteXPosition.Left) - - offsetX; - endY = - cy + - startNoteRenderer.y + - startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Center) - - sizeY; + endX = startNoteRenderer.x + startNoteRenderer.getNoteX(this._startNote, NoteXPosition.Left) - offsetX; + endY = startNoteRenderer.y + startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Center) - sizeY; startX = endX - sizeX; startY = - cy + - startNoteRenderer.y + - startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Center) + - sizeY; + startNoteRenderer.y + startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Center) + sizeY; break; case SlideInType.IntoFromAbove: - endX = - cx + - startNoteRenderer.x + - startNoteRenderer.getNoteX(this._startNote, NoteXPosition.Left) - - offsetX; - endY = - cy + - startNoteRenderer.y + - startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Center) + - sizeY; + endX = startNoteRenderer.x + startNoteRenderer.getNoteX(this._startNote, NoteXPosition.Left) - offsetX; + endY = startNoteRenderer.y + startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Center) + sizeY; startX = endX - sizeX; startY = - cy + - startNoteRenderer.y + - startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Center) - - sizeY; + startNoteRenderer.y + startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Center) - sizeY; break; default: - return; + return null; + } + return { startX, startY, endX, endY, waves: false }; + } + + private _computeSlideOut(): SlideSegment | null { + if (this._slideOutCacheValid) { + return this._slideOutCache; } - this._paintSlideLine(canvas, false, startX, endX, startY, endY); + const result = this._computeSlideOutUncached(); + this._slideOutCache = result; + this._slideOutCacheValid = true; + return result; } - private _paintSlideOut(cx: number, cy: number, canvas: ICanvas): void { + private _computeSlideOutUncached(): SlideSegment | null { const startNoteRenderer: TabBarRenderer = this.renderer as TabBarRenderer; const sizeX: number = this.renderer.smuflMetrics.simpleSlideWidth; const sizeY: number = this.renderer.smuflMetrics.simpleSlideHeight; @@ -108,11 +190,10 @@ export class TabSlideLineGlyph extends Glyph implements ITieGlyph { case SlideOutType.Shift: case SlideOutType.Legato: startX = - cx + startNoteRenderer.x + startNoteRenderer.getBeatX(this._startNote.beat, BeatXPosition.PostNotes) + offsetX; - startY = cy + startNoteRenderer.y + startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Center); + startY = startNoteRenderer.y + startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Center); if (this._startNote.slideTarget) { const endNoteRenderer: BarRendererBase | null = this.renderer.scoreRenderer.layout!.getRendererForBar( @@ -120,16 +201,14 @@ export class TabSlideLineGlyph extends Glyph implements ITieGlyph { this._startNote.slideTarget.beat.voice.bar ); if (!endNoteRenderer || endNoteRenderer.staff !== startNoteRenderer.staff) { - endX = cx + startNoteRenderer.x + startNoteRenderer.width; + endX = startNoteRenderer.x + startNoteRenderer.width; endY = startY; } else { endX = - cx + endNoteRenderer.x + endNoteRenderer.getBeatX(this._startNote.slideTarget.beat, BeatXPosition.OnNotes) - offsetX; endY = - cy + endNoteRenderer.y + endNoteRenderer.getNoteY(this._startNote.slideTarget, NoteYPosition.Center); } @@ -142,61 +221,39 @@ export class TabSlideLineGlyph extends Glyph implements ITieGlyph { endY += sizeY; } } else { - endX = cx + startNoteRenderer.x + this._parent.x; + endX = startNoteRenderer.x + this._parent.x; endY = startY; } break; case SlideOutType.OutUp: startX = - cx + - startNoteRenderer.x + - startNoteRenderer.getNoteX(this._startNote, NoteXPosition.Right) + - offsetX; + startNoteRenderer.x + startNoteRenderer.getNoteX(this._startNote, NoteXPosition.Right) + offsetX; startY = - cy + - startNoteRenderer.y + - startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Center) + - sizeY; + startNoteRenderer.y + startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Center) + sizeY; endX = startX + sizeX; - endY = - cy + - startNoteRenderer.y + - startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Center) - - sizeY; + endY = startNoteRenderer.y + startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Center) - sizeY; break; case SlideOutType.OutDown: startX = - cx + - startNoteRenderer.x + - startNoteRenderer.getNoteX(this._startNote, NoteXPosition.Right) + - offsetX; + startNoteRenderer.x + startNoteRenderer.getNoteX(this._startNote, NoteXPosition.Right) + offsetX; startY = - cy + - startNoteRenderer.y + - startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Center) - - sizeY; + startNoteRenderer.y + startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Center) - sizeY; endX = startX + sizeX; - endY = - cy + - startNoteRenderer.y + - startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Center) + - sizeY; + endY = startNoteRenderer.y + startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Center) + sizeY; break; case SlideOutType.PickSlideDown: startX = - cx + startNoteRenderer.x + startNoteRenderer.getNoteX(this._startNote, NoteXPosition.Right) + offsetX * 2; - startY = cy + startNoteRenderer.y + startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Center); - endX = cx + startNoteRenderer.x + startNoteRenderer.width; + startY = startNoteRenderer.y + startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Center); + endX = startNoteRenderer.x + startNoteRenderer.width; endY = startY + sizeY * 3; if ( this._startNote.beat.nextBeat && this._startNote.beat.nextBeat.voice === this._startNote.beat.voice ) { endX = - cx + startNoteRenderer.x + startNoteRenderer.getBeatX(this._startNote.beat.nextBeat, BeatXPosition.PreNotes); } @@ -204,28 +261,26 @@ export class TabSlideLineGlyph extends Glyph implements ITieGlyph { break; case SlideOutType.PickSlideUp: startX = - cx + startNoteRenderer.x + startNoteRenderer.getNoteX(this._startNote, NoteXPosition.Right) + offsetX * 2; - startY = cy + startNoteRenderer.y + startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Center); - endX = cx + startNoteRenderer.x + startNoteRenderer.width; + startY = startNoteRenderer.y + startNoteRenderer.getNoteY(this._startNote, NoteYPosition.Center); + endX = startNoteRenderer.x + startNoteRenderer.width; endY = startY - sizeY * 3; if ( this._startNote.beat.nextBeat && this._startNote.beat.nextBeat.voice === this._startNote.beat.voice ) { endX = - cx + startNoteRenderer.x + startNoteRenderer.getBeatX(this._startNote.beat.nextBeat, BeatXPosition.PreNotes); } waves = true; break; default: - return; + return null; } - this._paintSlideLine(canvas, waves, startX, endX, startY, endY); + return { startX, startY, endX, endY, waves }; } private _paintSlideLine( diff --git a/packages/alphatab/src/rendering/glyphs/TabWhammyBarGlyph.ts b/packages/alphatab/src/rendering/glyphs/TabWhammyBarGlyph.ts index 845d43169..8cf9f31cb 100644 --- a/packages/alphatab/src/rendering/glyphs/TabWhammyBarGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/TabWhammyBarGlyph.ts @@ -30,6 +30,42 @@ export class TabWhammyBarGlyph extends EffectGlyph { this._renderPoints = this._createRenderingPoints(beat); } + /** Right edge may reach into the next renderer's x for cross-bar curves. */ + public override getBoundingBoxLeft(): number { + if (this._isSimpleDip) { + return this.renderer.getBeatX(this._beat, BeatXPosition.OnNotes, true); + } + return this.renderer.getBeatX(this._beat, BeatXPosition.MiddleNotes, true); + } + + public override getBoundingBoxRight(): number { + if (this._isSimpleDip) { + return this.renderer.getBeatX(this._beat, BeatXPosition.PostNotes, true); + } + const nextBeat = this._beat.nextBeat; + if (nextBeat) { + const nextRenderer = this.renderer.scoreRenderer.layout!.getRendererForBar( + this.renderer.staff!.staffId, + nextBeat.voice.bar + ); + if (nextRenderer && nextRenderer.staff === this.renderer.staff) { + const sameRenderer = nextRenderer === this.renderer; + if (sameRenderer || nextBeat.hasWhammyBar) { + const endXPositionType: BeatXPosition = + nextBeat.hasWhammyBar && + (this.renderer.settings.notation.notationMode !== NotationMode.SongBook || + nextBeat.whammyBarType !== WhammyType.Dip) + ? BeatXPosition.MiddleNotes + : BeatXPosition.PreNotes; + return nextRenderer.x - this.renderer.x + nextRenderer.getBeatX(nextBeat, endXPositionType, true); + } + } + } + return ( + this.renderer.getBeatX(this._beat, BeatXPosition.EndBeat) - this.renderer.smuflMetrics.postNoteEffectPadding + ); + } + private _createRenderingPoints(beat: Beat): BendPoint[] { // advanced rendering if (beat.whammyBarType === WhammyType.Custom) { diff --git a/packages/alphatab/src/rendering/glyphs/TextGlyph.ts b/packages/alphatab/src/rendering/glyphs/TextGlyph.ts index 3ccfd6cc1..0a7ea604f 100644 --- a/packages/alphatab/src/rendering/glyphs/TextGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/TextGlyph.ts @@ -48,6 +48,28 @@ export class TextGlyph extends EffectGlyph { } } + public override getBoundingBoxLeft(): number { + switch (this.textAlign) { + case TextAlign.Center: + return this.x - this.width / 2; + case TextAlign.Right: + return this.x - this.width; + default: + return this.x; + } + } + + public override getBoundingBoxRight(): number { + switch (this.textAlign) { + case TextAlign.Center: + return this.x + this.width / 2; + case TextAlign.Right: + return this.x; + default: + return this.x + this.width; + } + } + public override paint(cx: number, cy: number, canvas: ICanvas): void { const color = canvas.color; canvas.color = this.colorOverride ?? color; diff --git a/packages/alphatab/src/rendering/glyphs/TieGlyph.ts b/packages/alphatab/src/rendering/glyphs/TieGlyph.ts index f19a036eb..eb0cea1b9 100644 --- a/packages/alphatab/src/rendering/glyphs/TieGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/TieGlyph.ts @@ -1,5 +1,5 @@ import type { Note } from '@coderline/alphatab/model/Note'; -import { TextAlign, TextBaseline, type ICanvas } from '@coderline/alphatab/platform/ICanvas'; +import { type ICanvas, TextAlign, TextBaseline } from '@coderline/alphatab/platform/ICanvas'; import { type BarRendererBase, NoteXPosition, NoteYPosition } from '@coderline/alphatab/rendering/BarRendererBase'; import { Glyph } from '@coderline/alphatab/rendering/glyphs/Glyph'; import type { ResolvedTieGlyphLabel, TieGlyphLabel } from '@coderline/alphatab/rendering/glyphs/TieGlyphLabel'; @@ -16,6 +16,10 @@ export interface ITieGlyph { * If set, the tie bounds will be requested and the overflow is applied. */ readonly checkForOverflow: boolean; + getBoundingBoxTop(): number; + getBoundingBoxBottom(): number; + getBoundingBoxLeft(): number; + getBoundingBoxRight(): number; } /** @@ -50,6 +54,7 @@ export abstract class TieGlyph extends Glyph implements ITieGlyph { return this._shouldPaint && this._boundingBox !== undefined; } + /** Renderer-local Y. Staff finalize may shift `renderer.y` after tie layout, so geometry stays renderer-relative. */ public override getBoundingBoxTop(): number { if (this._boundingBox) { return this._boundingBox!.y; @@ -64,6 +69,21 @@ export abstract class TieGlyph extends Glyph implements ITieGlyph { return this._startY; } + /** Staff-absolute X — `_startX`/`_endX` bake in their renderers' `.x` for cross-bar ties. */ + public override getBoundingBoxLeft(): number { + if (this._boundingBox) { + return this._boundingBox.x; + } + return this._startX; + } + + public override getBoundingBoxRight(): number { + if (this._boundingBox) { + return this._boundingBox.x + this._boundingBox.w; + } + return this._endX; + } + public override doLayout(): void { this.width = 0; @@ -179,9 +199,10 @@ export abstract class TieGlyph extends Glyph implements ITieGlyph { // Single Y line for all labels — the outer arc apex. // Painted offset adds `padding` on the outward side, so // every label sits the same fixed distance from its arc. - const labelLineY = cps.length > 0 - ? 0.125 * cps[7] + 0.375 * cps[9] + 0.375 * cps[11] + 0.125 * cps[13] - : (this._startY + this._endY) / 2; + const labelLineY = + cps.length > 0 + ? 0.125 * cps[7] + 0.375 * cps[9] + 0.375 * cps[11] + 0.125 * cps[13] + : (this._startY + this._endY) / 2; for (const label of labels) { const fromX = this.resolveLabelAnchorX(label.fromNote); @@ -254,15 +275,18 @@ export abstract class TieGlyph extends Glyph implements ITieGlyph { return; } + // Tie Y is renderer-local; resolve `renderer.y` at paint time. + const rendererY = this.renderer.y; + const isDown = this.tieDirection === BeamDirection.Down; if (this.shouldDrawBendSlur()) { TieGlyph.drawBendSlur( canvas, cx + this._startX, - cy + this._startY, + cy + rendererY + this._startY, cx + this._endX, - cy + this._endY, + cy + rendererY + this._endY, isDown, this.renderer.smuflMetrics.tieHeight ); @@ -271,9 +295,9 @@ export abstract class TieGlyph extends Glyph implements ITieGlyph { canvas, 1, cx + this._startX, - cy + this._startY, + cy + rendererY + this._startY, cx + this._endX, - cy + this._endY, + cy + rendererY + this._endY, isDown, this._tieHeight, this.renderer.smuflMetrics.tieMidpointThickness @@ -293,7 +317,7 @@ export abstract class TieGlyph extends Glyph implements ITieGlyph { canvas.font = res.getFontForNotationElement(label.element); lastElement = label.element; } - canvas.fillText(label.text, cx + label.x, cy + label.y + this._labelBaselineOffset); + canvas.fillText(label.text, cx + label.x, cy + rendererY + label.y + this._labelBaselineOffset); } canvas.textAlign = ta; canvas.textBaseline = tb; @@ -368,11 +392,8 @@ export abstract class TieGlyph extends Glyph implements ITieGlyph { protected abstract calculateEndX(): number; - public calculateMultiSystemSlurY(renderer: BarRendererBase) { - const startRenderer = this.lookupStartBeatRenderer(); - const startY = this.calculateStartY(); - const relY = startY - startRenderer.y; - return renderer.y + relY; + public calculateMultiSystemSlurY(_renderer: BarRendererBase) { + return this.calculateStartY(); } public shouldCreateMultiSystemSlur(renderer: BarRendererBase) { @@ -531,7 +552,6 @@ export abstract class TieGlyph extends Glyph implements ITieGlyph { return [rotateX + rx, rotateY + ry]; } - public static paintTie( canvas: ICanvas, scale: number, @@ -727,14 +747,14 @@ export abstract class NoteTieGlyph extends TieGlyph { protected override calculateStartY(): number { const startNoteRenderer = this.lookupStartBeatRenderer(); if (this.isLeftHandTap) { - return startNoteRenderer.y + startNoteRenderer.getNoteY(this.startNote, NoteYPosition.Center); + return startNoteRenderer.getNoteY(this.startNote, NoteYPosition.Center); } switch (this.tieDirection) { case BeamDirection.Up: - return startNoteRenderer.y + startNoteRenderer!.getNoteY(this.startNote, NoteYPosition.Top); + return startNoteRenderer!.getNoteY(this.startNote, NoteYPosition.Top); default: - return startNoteRenderer.y + startNoteRenderer.getNoteY(this.startNote, NoteYPosition.Bottom); + return startNoteRenderer.getNoteY(this.startNote, NoteYPosition.Bottom); } } @@ -760,14 +780,14 @@ export abstract class NoteTieGlyph extends TieGlyph { } if (this.isLeftHandTap) { - return endNoteRenderer.y + endNoteRenderer!.getNoteY(this.endNote, NoteYPosition.Center); + return endNoteRenderer!.getNoteY(this.endNote, NoteYPosition.Center); } switch (this.tieDirection) { case BeamDirection.Up: - return endNoteRenderer.y + endNoteRenderer!.getNoteY(this.endNote, NoteYPosition.Top); + return endNoteRenderer!.getNoteY(this.endNote, NoteYPosition.Top); default: - return endNoteRenderer.y + endNoteRenderer!.getNoteY(this.endNote, NoteYPosition.Bottom); + return endNoteRenderer!.getNoteY(this.endNote, NoteYPosition.Bottom); } } diff --git a/packages/alphatab/src/rendering/glyphs/TrillGlyph.ts b/packages/alphatab/src/rendering/glyphs/TrillGlyph.ts index 20f1b4377..89897101a 100644 --- a/packages/alphatab/src/rendering/glyphs/TrillGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/TrillGlyph.ts @@ -1,5 +1,5 @@ -import type { ICanvas } from '@coderline/alphatab/platform/ICanvas'; import { MusicFontSymbol } from '@coderline/alphatab/model/MusicFontSymbol'; +import type { ICanvas } from '@coderline/alphatab/platform/ICanvas'; import { BeatXPosition } from '@coderline/alphatab/rendering/BeatXPosition'; import { GroupedEffectGlyph } from '@coderline/alphatab/rendering/glyphs/GroupedEffectGlyph'; @@ -18,6 +18,11 @@ export class TrillGlyph extends GroupedEffectGlyph { this.height = this.renderer.smuflMetrics.glyphHeights.get(MusicFontSymbol.OrnamentTrill)!; } + public override getBoundingBoxLeft(): number { + const trillSize = this.renderer.smuflMetrics.glyphWidths.get(MusicFontSymbol.OrnamentTrill)!; + return this.x - trillSize / 2; + } + protected override paintGrouped(cx: number, cy: number, endX: number, canvas: ICanvas): void { const trillSize = this.renderer.smuflMetrics.glyphWidths.get(MusicFontSymbol.OrnamentTrill)!; const startX: number = cx + this.x; diff --git a/packages/alphatab/src/rendering/glyphs/TripletFeelGlyph.ts b/packages/alphatab/src/rendering/glyphs/TripletFeelGlyph.ts index 3b522da82..ba9021a11 100644 --- a/packages/alphatab/src/rendering/glyphs/TripletFeelGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/TripletFeelGlyph.ts @@ -1,8 +1,8 @@ -import { TripletFeel } from '@coderline/alphatab/model/TripletFeel'; -import { CanvasHelper, TextAlign, TextBaseline, type ICanvas } from '@coderline/alphatab/platform/ICanvas'; -import { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; import { MusicFontSymbol } from '@coderline/alphatab/model/MusicFontSymbol'; +import { TripletFeel } from '@coderline/alphatab/model/TripletFeel'; import { NotationElement } from '@coderline/alphatab/NotationSettings'; +import { CanvasHelper, type ICanvas, TextAlign, TextBaseline } from '@coderline/alphatab/platform/ICanvas'; +import { EffectGlyph } from '@coderline/alphatab/rendering/glyphs/EffectGlyph'; /** * @internal @@ -18,6 +18,15 @@ enum TripletFeelNoteGroup { ThirtySecondSixteenthDotted = 7 } +/** + * @record + * @internal + */ +interface TripletFeelGroupPair { + left: TripletFeelNoteGroup; + right: TripletFeelNoteGroup; +} + /** * @internal */ @@ -25,6 +34,7 @@ export class TripletFeelGlyph extends EffectGlyph { private _tripletFeel: TripletFeel; private _tupletHeight: number = 0; private _tupletPadding: number = 0; + private _paintWidth: number = 0; public constructor(tripletFeel: TripletFeel) { super(0, 0); @@ -39,6 +49,74 @@ export class TripletFeelGlyph extends EffectGlyph { this._tupletHeight = this.renderer.smuflMetrics.glyphHeights.get(MusicFontSymbol.Tuplet3)! * noteScale; this._tupletPadding = this.renderer.smuflMetrics.tripletFeelBracketPadding; this.height += this._tupletHeight; + + // `width = 0` for rhythmic spacing; pre-compute the paint width once for bbox. + const noteSpacing = this.renderer.smuflMetrics.glyphWidths.get(MusicFontSymbol.MetNoteQuarterUp)! * noteScale; + const groups = TripletFeelGlyph._resolveGroups(this._tripletFeel); + const canvas = this.renderer.scoreRenderer.canvas!; + canvas.font = this.renderer.resources.elementFonts.get(NotationElement.EffectTripletFeel)!; + const parenOpenW = canvas.measureText('( ').width; + const equalsW = canvas.measureText(' = ').width; + const parenCloseW = canvas.measureText(' )').width; + const group1W = TripletFeelGlyph._groupAdvance(groups.left, noteSpacing); + const group2W = TripletFeelGlyph._groupAdvance(groups.right, noteSpacing); + this._paintWidth = parenOpenW + group1W + equalsW + group2W + parenCloseW; + } + + public override getBoundingBoxRight(): number { + return this.x + this._paintWidth; + } + + /** Mirrors the trailing `cx += noteSpacing` branch in {@link _drawGroup}. */ + private static _groupAdvance(group: TripletFeelNoteGroup, noteSpacing: number): number { + switch (group) { + case TripletFeelNoteGroup.QuarterTripletEighthTriplet: + case TripletFeelNoteGroup.EighthDottedSixteenth: + case TripletFeelNoteGroup.SixteenthDottedThirtySecond: + return 4 * noteSpacing; + default: + return 3 * noteSpacing; + } + } + + /** Mirrors the `switch (this._tripletFeel)` mapping in {@link paint}. */ + private static _resolveGroups(tripletFeel: TripletFeel): TripletFeelGroupPair { + switch (tripletFeel) { + case TripletFeel.NoTripletFeel: + return { left: TripletFeelNoteGroup.EighthEighth, right: TripletFeelNoteGroup.EighthEighth }; + case TripletFeel.Triplet8th: + return { + left: TripletFeelNoteGroup.EighthEighth, + right: TripletFeelNoteGroup.QuarterTripletEighthTriplet + }; + case TripletFeel.Triplet16th: + return { + left: TripletFeelNoteGroup.SixteenthSixteenth, + right: TripletFeelNoteGroup.EighthSixteenthTriplet + }; + case TripletFeel.Dotted8th: + return { + left: TripletFeelNoteGroup.EighthEighth, + right: TripletFeelNoteGroup.EighthDottedSixteenth + }; + case TripletFeel.Dotted16th: + return { + left: TripletFeelNoteGroup.SixteenthSixteenth, + right: TripletFeelNoteGroup.SixteenthDottedThirtySecond + }; + case TripletFeel.Scottish8th: + return { + left: TripletFeelNoteGroup.EighthEighth, + right: TripletFeelNoteGroup.SixteenthEighthDotted + }; + case TripletFeel.Scottish16th: + return { + left: TripletFeelNoteGroup.SixteenthSixteenth, + right: TripletFeelNoteGroup.ThirtySecondSixteenthDotted + }; + default: + return { left: TripletFeelNoteGroup.EighthEighth, right: TripletFeelNoteGroup.EighthEighth }; + } } public override paint(cx: number, cy: number, canvas: ICanvas): void { diff --git a/packages/alphatab/src/rendering/layout/HorizontalScreenLayout.ts b/packages/alphatab/src/rendering/layout/HorizontalScreenLayout.ts index f613fe331..746e53c99 100644 --- a/packages/alphatab/src/rendering/layout/HorizontalScreenLayout.ts +++ b/packages/alphatab/src/rendering/layout/HorizontalScreenLayout.ts @@ -44,15 +44,14 @@ export class HorizontalScreenLayout extends ScoreLayout { public doResize(): void { // not supported } - + public override doUpdateForBars(_renderHints: RenderHints): boolean { // not supported yet, modifications likely cause anyhow full updates - // as we do not optimize effect bands yet. with effect bands being more + // as we do not optimize effect bands yet. with effect bands being more // isolated in bars we could try updating dynamically return false; } - protected doLayoutAndRender(renderHints: RenderHints | undefined): void { const score: Score = this.renderer.score!; @@ -176,9 +175,8 @@ export class HorizontalScreenLayout extends ScoreLayout { for (const r of result.renderers) { const barDisplayWidth = r.staff!.system.staves.length > 1 ? r.bar.masterBar.displayWidth : r.bar.displayWidth; - if (barDisplayWidth > 0) { - r.scaleToWidth(barDisplayWidth); - } + // Fall back to natural width so `scaleToWidth` still runs. + r.scaleToWidth(barDisplayWidth > 0 ? barDisplayWidth : r.width); const w = r.x + r.width; if (w > result.width) { result.width = w; @@ -217,16 +215,12 @@ export class HorizontalScreenLayout extends ScoreLayout { private _alignRenderers(): void { this.width = 0; const system = this._system!; + // `_scaleBars` already ran. supportsResize=false ⇒ fresh + // StaffSystem per render, so no shared-layout-data reset is needed. for (const s of system.allStaves) { - s.resetSharedLayoutData(); - let w = 0; for (const renderer of s.barRenderers) { renderer.x = w; - renderer.y = s.topPadding + s.topOverflow; - // note: this will ensure aspects like beaming helpers - // and overflows are prepared for finalization - renderer.scaleToWidth(renderer.width); w += renderer.width; } diff --git a/packages/alphatab/src/rendering/layout/ScoreLayout.ts b/packages/alphatab/src/rendering/layout/ScoreLayout.ts index cc6585301..3828d17f8 100644 --- a/packages/alphatab/src/rendering/layout/ScoreLayout.ts +++ b/packages/alphatab/src/rendering/layout/ScoreLayout.ts @@ -20,6 +20,7 @@ import type { RenderHints } from '@coderline/alphatab/rendering/IScoreRenderer'; import { SlurRegistry } from '@coderline/alphatab/rendering/layout/SlurRegistry'; import { RenderFinishedEventArgs } from '@coderline/alphatab/rendering/RenderFinishedEventArgs'; import type { ScoreRenderer } from '@coderline/alphatab/rendering/ScoreRenderer'; +import { SkylineSegmentPool } from '@coderline/alphatab/rendering/skyline/SkylineSegmentPool'; import { RenderStaff } from '@coderline/alphatab/rendering/staves/RenderStaff'; import { StaffSystem } from '@coderline/alphatab/rendering/staves/StaffSystem'; import type { BeamingRuleLookup } from '@coderline/alphatab/rendering/utils/BeamingRuleLookup'; @@ -53,6 +54,8 @@ export abstract class ScoreLayout { public width: number = 0; public height: number = 0; + public readonly skylinePool: SkylineSegmentPool = new SkylineSegmentPool(); + public multiBarRestInfo: Map | null = null; public get scaledWidth() { @@ -125,7 +128,7 @@ export abstract class ScoreLayout { private _lazyPartials: Map = new Map(); - protected getExistingPartialArgs(id:string): RenderFinishedEventArgs|undefined { + protected getExistingPartialArgs(id: string): RenderFinishedEventArgs | undefined { return this._lazyPartials.has(id) ? this._lazyPartials.get(id)!.args : undefined; } diff --git a/packages/alphatab/src/rendering/layout/VerticalLayoutBase.ts b/packages/alphatab/src/rendering/layout/VerticalLayoutBase.ts index 93ee2543b..81f6b7d24 100644 --- a/packages/alphatab/src/rendering/layout/VerticalLayoutBase.ts +++ b/packages/alphatab/src/rendering/layout/VerticalLayoutBase.ts @@ -2,13 +2,13 @@ import type { EventEmitterOfT } from '@coderline/alphatab/EventEmitter'; import { Logger } from '@coderline/alphatab/Logger'; import { ScoreSubElement } from '@coderline/alphatab/model/Score'; import { type ICanvas, TextAlign } from '@coderline/alphatab/platform/ICanvas'; +import type { RenderingResources } from '@coderline/alphatab/RenderingResources'; import type { TextGlyph } from '@coderline/alphatab/rendering/glyphs/TextGlyph'; import type { RenderHints } from '@coderline/alphatab/rendering/IScoreRenderer'; import { ScoreLayout } from '@coderline/alphatab/rendering/layout/ScoreLayout'; import { RenderFinishedEventArgs } from '@coderline/alphatab/rendering/RenderFinishedEventArgs'; import type { MasterBarsRenderers } from '@coderline/alphatab/rendering/staves/MasterBarsRenderers'; import type { StaffSystem } from '@coderline/alphatab/rendering/staves/StaffSystem'; -import type { RenderingResources } from '@coderline/alphatab/RenderingResources'; /** * Base layout for page and parchment style layouts where we have an endless @@ -20,6 +20,10 @@ export abstract class VerticalLayoutBase extends ScoreLayout { private _allMasterBarRenderers: MasterBarsRenderers[] = []; private _barsFromPreviousSystem: MasterBarsRenderers[] = []; + public get systems(): StaffSystem[] { + return this._systems; + } + private _reuseViewPort: boolean = false; private _preSystemPartialIds: string[] = []; @@ -335,10 +339,12 @@ export abstract class VerticalLayoutBase extends ScoreLayout { system.y = y; } } - system.isLast = this.lastBarIndex === system.lastBarIndex; - // don't forget to finish the last system - this._fitSystem(system); - y += this._paintSystem(system, oldHeight); + if (system.masterBarsRenderers.length > 0) { + system.isLast = this.lastBarIndex === system.lastBarIndex; + this._systems.push(system); + this._fitSystem(system); + y += this._paintSystem(system, oldHeight); + } } return y; } @@ -444,15 +450,14 @@ export abstract class VerticalLayoutBase extends ScoreLayout { const distributable = Math.max(0, staffWidth - system.totalFixedOverhead); const contentShare = weightTotal > 0 ? distributable / weightTotal : 0; - for (const s of system.allStaves) { - s.resetSharedLayoutData(); + system.resetAllStavesSharedLayoutData(); + for (const s of system.allStaves) { let w = 0; for (let i = 0; i < s.barRenderers.length; i++) { const renderer = s.barRenderers[i]; const mb = system.masterBarsRenderers[i]; renderer.x = w; - renderer.y = s.topPadding + s.topOverflow; const weight = shouldApplyBarScale ? system.getBarDisplayScale(renderer) : mb.maxContentWidth; const actualBarWidth = mb.maxFixedOverhead + weight * contentShare; diff --git a/packages/alphatab/src/rendering/skyline/BarLocalSkyline.ts b/packages/alphatab/src/rendering/skyline/BarLocalSkyline.ts new file mode 100644 index 000000000..2bfbebd16 --- /dev/null +++ b/packages/alphatab/src/rendering/skyline/BarLocalSkyline.ts @@ -0,0 +1,35 @@ +import { Skyline } from '@coderline/alphatab/rendering/skyline/Skyline'; +import type { SkylineSegmentPool } from '@coderline/alphatab/rendering/skyline/SkylineSegmentPool'; + +/** @internal */ +export enum StaffSide { + Top = 0, + Bottom = 1 +} + +/** + * Bar-local Skyline pair (renderer-local x). + * @internal + */ +export class BarLocalSkyline { + public readonly upSky: Skyline; + public readonly downSky: Skyline; + + public constructor(xMin: number, xMax: number, pool: SkylineSegmentPool) { + this.upSky = new Skyline(xMin, xMax, pool); + this.downSky = new Skyline(xMin, xMax, pool); + } + + public insertPlaced(side: StaffSide, xStart: number, xEnd: number, outerEdgeHeight: number, pad: number): void { + if (side === StaffSide.Top) { + this.upSky.insert(xStart, xEnd, outerEdgeHeight, pad); + } else { + this.downSky.insert(xStart, xEnd, outerEdgeHeight, pad); + } + } + + public reset(): void { + this.upSky.reset(); + this.downSky.reset(); + } +} diff --git a/packages/alphatab/src/rendering/skyline/Skyline.ts b/packages/alphatab/src/rendering/skyline/Skyline.ts new file mode 100644 index 000000000..8194de9b1 --- /dev/null +++ b/packages/alphatab/src/rendering/skyline/Skyline.ts @@ -0,0 +1,350 @@ +import type { SkylineSegment, SkylineSegmentPool } from '@coderline/alphatab/rendering/skyline/SkylineSegmentPool'; + +/** + * Piecewise-constant step-function skyline used as a placement oracle. + * Heights are non-negative magnitudes measured outward from a reference edge. + * @internal + */ +export class Skyline { + public readonly xMin: number; + public readonly xMax: number; + + private readonly _pool: SkylineSegmentPool; + private readonly _segments: SkylineSegment[] = []; + + public constructor(xMin: number, xMax: number, pool: SkylineSegmentPool) { + this.xMin = xMin; + this.xMax = xMax; + this._pool = pool; + this._initBaseline(); + } + + public get segmentCount(): number { + return this._segments.length - 1; + } + + public forEachSegment(cb: (xStart: number, xEnd: number, height: number) => void): void { + for (let k: number = 0; k < this._segments.length - 1; k = k + 1) { + cb(this._segments[k].xStart, this._segments[k + 1].xStart, this._segments[k].height); + } + } + + /** + * Index-based segment accessors. `segmentCount` returns the number of + * non-sentinel segments; valid indices are `[0, segmentCount)`. These + * exist so hot consumers can iterate without allocating a closure (as + * `forEachSegment` does), which matters in transpile targets (C#/Kotlin) + * where closures are not free. The sentinel segment at the tail is not + * exposed. + */ + public segmentXStart(i: number): number { + return this._segments[i].xStart; + } + + public segmentXEnd(i: number): number { + return this._segments[i + 1].xStart; + } + + public segmentHeight(i: number): number { + return this._segments[i].height; + } + + public placeAbove(xStart: number, xEnd: number, _intrinsicHeight: number, pad: number): number { + return this._maxHeightInRange(xStart, xEnd) + pad; + } + + public placeBelow(xStart: number, xEnd: number, _intrinsicHeight: number, pad: number): number { + return this._maxHeightInRange(xStart, xEnd) + pad; + } + + public insert(xStart: number, xEnd: number, outerEdgeHeight: number, _pad: number): void { + this._raiseRange(xStart, xEnd, outerEdgeHeight); + } + + public union(other: Skyline): void { + const o: SkylineSegment[] = other._segments; + for (let k: number = 0; k < o.length - 1; k = k + 1) { + const segStart: number = o[k].xStart; + const segEnd: number = o[k + 1].xStart; + const h: number = o[k].height; + if (h > 0) { + this._raiseRange(segStart, segEnd, h); + } + } + } + + /** + * Unions every segment of `other` into `this`, shifted by `dx` on the + * x-axis (i.e. each other-segment `[xs, xe)` contributes height `h` to + * the range `[xs + dx, xe + dx)`). Clamps to `[xMin, xMax]`. + * + * Pair-merge implementation: walks `this._segments` and `other._segments` + * in lockstep and emits a fresh segment list in O(s_this + s_other) time. + * Each emitted segment's height is `max(thisH, otherH)` for that span; + * adjacent equal-height pieces are coalesced inline so the result stays + * in canonical form (no two consecutive segments with equal height). The + * sentinel structure is preserved by appending a fresh sentinel at + * `xMax` after the sweep. Old segments are returned to the pool. + * + * This replaces the previous "iterate other + raise per segment" path, + * which was O(s_other * s_this) due to repeated split+raise on `this`. + */ + public unionShifted(other: Skyline, dx: number): void { + const o: SkylineSegment[] = other._segments; + const oLast: number = o.length - 1; // sentinel index in other + const xMin: number = this.xMin; + const xMax: number = this.xMax; + + // Short-circuit: other entirely out of [xMin, xMax] after shifting. + if (o[oLast].xStart + dx <= xMin || o[0].xStart + dx >= xMax) { + return; + } + // Short-circuit: other has no positive-height segments at all. + let hasContent: boolean = false; + for (let k: number = 0; k < oLast; k = k + 1) { + if (o[k].height > 0) { + hasContent = true; + break; + } + } + if (!hasContent) { + return; + } + + const t: SkylineSegment[] = this._segments; + const tLast: number = t.length - 1; // sentinel index in this + + const newSegs: SkylineSegment[] = []; + + let i: number = 0; // current segment index in this (active over [t[i].xStart, t[i+1].xStart)) + let j: number = 0; // current segment index in other + + // Skip other's segments that end at or before xMin. + while (j < oLast && o[j + 1].xStart + dx <= xMin) { + j = j + 1; + } + + let x: number = xMin; + while (x < xMax) { + const thisH: number = i < tLast ? t[i].height : 0; + let otherH: number = 0; + if (j < oLast) { + const oStart: number = o[j].xStart + dx; + if (x >= oStart) { + otherH = o[j].height; + } + } + const h: number = thisH > otherH ? thisH : otherH; + + // Find next breakpoint. + let nextX: number = xMax; + if (i < tLast) { + const tNext: number = t[i + 1].xStart; + if (tNext < nextX) { + nextX = tNext; + } + } + if (j < oLast) { + const oStart: number = o[j].xStart + dx; + if (x < oStart) { + if (oStart < nextX) { + nextX = oStart; + } + } else { + const oExit: number = o[j + 1].xStart + dx; + if (oExit < nextX) { + nextX = oExit; + } + } + } + if (nextX > xMax) { + nextX = xMax; + } + // Safety: guarantee forward progress. Should not trigger for + // valid sorted inputs, but avoid an infinite loop on degenerate + // (zero-width) inputs. + if (nextX <= x) { + nextX = xMax; + } + + // Emit, coalescing adjacent equal-height pieces. + if (newSegs.length > 0 && newSegs[newSegs.length - 1].height === h) { + // skip — previous segment already covers up to nextX implicitly + } else { + const ns: SkylineSegment = this._pool.acquire(); + ns.xStart = x; + ns.height = h; + newSegs.push(ns); + } + + x = nextX; + + // Advance i while its right edge is at or before x. + while (i < tLast && t[i + 1].xStart <= x) { + i = i + 1; + } + // Advance j similarly (shifted). + while (j < oLast && o[j + 1].xStart + dx <= x) { + j = j + 1; + } + } + + // Append sentinel at xMax. + const sentinel: SkylineSegment = this._pool.acquire(); + sentinel.xStart = xMax; + sentinel.height = 0; + newSegs.push(sentinel); + + // Release old segments and swap in the new list. + while (this._segments.length > 0) { + const s: SkylineSegment = this._segments.pop()!; + this._pool.release(s); + } + for (let k: number = 0; k < newSegs.length; k = k + 1) { + this._segments.push(newSegs[k]); + } + } + + public maxHeightInRange(xStart: number, xEnd: number): number { + return this._maxHeightInRange(xStart, xEnd); + } + + public maxHeight(): number { + let best: number = 0; + for (let k: number = 0; k < this._segments.length - 1; k = k + 1) { + const h: number = this._segments[k].height; + if (h > best) { + best = h; + } + } + return best; + } + + public reset(): void { + while (this._segments.length > 0) { + const s: SkylineSegment = this._segments.pop()!; + this._pool.release(s); + } + this._initBaseline(); + } + + private _initBaseline(): void { + const first: SkylineSegment = this._pool.acquire(); + first.xStart = this.xMin; + first.height = 0; + this._segments.push(first); + const sentinel: SkylineSegment = this._pool.acquire(); + sentinel.xStart = this.xMax; + sentinel.height = 0; + this._segments.push(sentinel); + } + + private _maxHeightInRange(xStart: number, xEnd: number): number { + const lo: number = xStart > this.xMin ? xStart : this.xMin; + const hi: number = xEnd < this.xMax ? xEnd : this.xMax; + if (lo >= hi) { + return 0; + } + let best: number = 0; + for (let k: number = 0; k < this._segments.length - 1; k = k + 1) { + const segStart: number = this._segments[k].xStart; + const segEnd: number = this._segments[k + 1].xStart; + if (segEnd <= lo) { + continue; + } + if (segStart >= hi) { + break; + } + const h: number = this._segments[k].height; + if (h > best) { + best = h; + } + } + return best; + } + + private _raiseRange(xStart: number, xEnd: number, newHeight: number): void { + const lo: number = xStart > this.xMin ? xStart : this.xMin; + const hi: number = xEnd < this.xMax ? xEnd : this.xMax; + if (lo >= hi || newHeight <= 0) { + return; + } + // Split at `lo`, capturing the index of the first segment in the + // raised range. Splitting at `hi` then may shift `hi` indices but + // never the `lo` index, so it is safe to keep `loIdx` afterwards. + const loIdx: number = this._splitAt(lo); + const hiIdx: number = this._splitAt(hi); + // Raise heights over the half-open index range [loIdx, hiIdx). + for (let i: number = loIdx; i < hiIdx; i = i + 1) { + if (this._segments[i].height < newHeight) { + this._segments[i].height = newHeight; + } + } + // Merge only inside the touched window: any new same-height + // adjacency can only appear at the boundary with the left + // neighbour (loIdx-1 / loIdx), inside the raised range itself + // (segments that were already >= newHeight stay distinct from + // those that were raised to newHeight), or at the boundary with + // the right neighbour (hiIdx-1 / hiIdx). Segments outside this + // window are untouched and were already in canonical form. + const mergeLo: number = loIdx > 0 ? loIdx - 1 : 0; + // hiIdx is the index of the first segment AFTER the raised range. + // We must consider the adjacency between hiIdx-1 and hiIdx, so + // iterate up to and including hiIdx-1. + let mergeIdx: number = mergeLo; + let mergeEnd: number = hiIdx; // upper bound (inclusive) on left-of-pair index + // Cap `mergeEnd` so we never look past the last non-sentinel segment. + if (mergeEnd > this._segments.length - 2) { + mergeEnd = this._segments.length - 2; + } + while (mergeIdx <= mergeEnd) { + if ( + mergeIdx < this._segments.length - 2 && + this._segments[mergeIdx].height === this._segments[mergeIdx + 1].height + ) { + const removed: SkylineSegment = this._segments.splice(mergeIdx + 1, 1)[0]; + this._pool.release(removed); + mergeEnd = mergeEnd - 1; + // do not advance mergeIdx: re-check current index against the new neighbour + } else { + mergeIdx = mergeIdx + 1; + } + } + } + + /** + * Splits the skyline at `x` so that some segment afterwards has + * `xStart === x`. Returns the index of that segment. If `x <= xMin` + * the baseline (index 0) is returned. If `x >= xMax` the sentinel + * index (`_segments.length - 1`) is returned. + */ + private _splitAt(x: number): number { + if (x <= this.xMin) { + return 0; + } + if (x >= this.xMax) { + return this._segments.length - 1; + } + // Binary search for the largest index k with segments[k].xStart <= x. + // Segments are sorted strictly increasing by xStart. + let lo: number = 0; + let hi: number = this._segments.length - 1; + while (lo < hi) { + const mid: number = Math.floor((lo + hi + 1) / 2); + if (this._segments[mid].xStart <= x) { + lo = mid; + } else { + hi = mid - 1; + } + } + // Now segments[lo].xStart <= x < segments[lo+1].xStart. + if (this._segments[lo].xStart === x) { + return lo; + } + const newSeg: SkylineSegment = this._pool.acquire(); + newSeg.xStart = x; + newSeg.height = this._segments[lo].height; + this._segments.splice(lo + 1, 0, newSeg); + return lo + 1; + } +} diff --git a/packages/alphatab/src/rendering/skyline/SkylineSegmentPool.ts b/packages/alphatab/src/rendering/skyline/SkylineSegmentPool.ts new file mode 100644 index 000000000..db200b585 --- /dev/null +++ b/packages/alphatab/src/rendering/skyline/SkylineSegmentPool.ts @@ -0,0 +1,40 @@ +/** + * One segment of a piecewise-constant skyline. segment[i] covers + * `[xStart, segments[i+1].xStart)`; final entry is a sentinel. + * @internal + */ +export class SkylineSegment { + public xStart: number = 0; + public height: number = 0; + + public reset(): void { + this.xStart = 0; + this.height = 0; + } +} + +/** @internal */ +export class SkylineSegmentPool { + private readonly _free: SkylineSegment[] = []; + private _grown: number = 0; + + public acquire(): SkylineSegment { + let s: SkylineSegment; + if (this._free.length > 0) { + s = this._free.pop()!; + } else { + s = new SkylineSegment(); + this._grown = this._grown + 1; + } + s.reset(); + return s; + } + + public release(s: SkylineSegment): void { + this._free.push(s); + } + + public get grownCount(): number { + return this._grown; + } +} diff --git a/packages/alphatab/src/rendering/skyline/StaffSystemSkyline.ts b/packages/alphatab/src/rendering/skyline/StaffSystemSkyline.ts new file mode 100644 index 000000000..a94e56934 --- /dev/null +++ b/packages/alphatab/src/rendering/skyline/StaffSystemSkyline.ts @@ -0,0 +1,35 @@ +import { StaffSide } from '@coderline/alphatab/rendering/skyline/BarLocalSkyline'; +import { Skyline } from '@coderline/alphatab/rendering/skyline/Skyline'; +import type { SkylineSegmentPool } from '@coderline/alphatab/rendering/skyline/SkylineSegmentPool'; + +/** + * Skyline pair for system-level placement. Assembled by unioning per-bar + * local skylines shifted by `renderer.x`. + * @internal + */ +export class StaffSystemSkyline { + public readonly staffIndex: number; + public readonly systemIndex: number; + public readonly upSky: Skyline; + public readonly downSky: Skyline; + + public constructor(staffIndex: number, systemIndex: number, xMin: number, xMax: number, pool: SkylineSegmentPool) { + this.staffIndex = staffIndex; + this.systemIndex = systemIndex; + this.upSky = new Skyline(xMin, xMax, pool); + this.downSky = new Skyline(xMin, xMax, pool); + } + + public insertPlaced(side: StaffSide, xStart: number, xEnd: number, outerEdgeHeight: number, pad: number): void { + if (side === StaffSide.Top) { + this.upSky.insert(xStart, xEnd, outerEdgeHeight, pad); + } else { + this.downSky.insert(xStart, xEnd, outerEdgeHeight, pad); + } + } + + public reset(): void { + this.upSky.reset(); + this.downSky.reset(); + } +} diff --git a/packages/alphatab/src/rendering/staves/RenderStaff.ts b/packages/alphatab/src/rendering/staves/RenderStaff.ts index 5a0aa6be1..e4cb9ad11 100644 --- a/packages/alphatab/src/rendering/staves/RenderStaff.ts +++ b/packages/alphatab/src/rendering/staves/RenderStaff.ts @@ -7,6 +7,8 @@ import { type EffectBandInfo, EffectBandMode } from '@coderline/alphatab/rendering/BarRendererFactory'; +import { EffectSystemPlacement } from '@coderline/alphatab/rendering/EffectSystemPlacement'; +import { StaffSystemSkyline } from '@coderline/alphatab/rendering/skyline/StaffSystemSkyline'; import type { BarLayoutingInfo } from '@coderline/alphatab/rendering/staves/BarLayoutingInfo'; import type { StaffSystem } from '@coderline/alphatab/rendering/staves/StaffSystem'; import type { StaffTrackGroup } from '@coderline/alphatab/rendering/staves/StaffTrackGroup'; @@ -206,6 +208,48 @@ export class RenderStaff { } } + private _systemSkyline: StaffSystemSkyline | null = null; + private _effectPlacement: EffectSystemPlacement | null = null; + + public get effectPlacement(): EffectSystemPlacement { + if (!this._effectPlacement) { + this._effectPlacement = new EffectSystemPlacement(this); + } + return this._effectPlacement; + } + + public get systemSkyline(): StaffSystemSkyline { + if (!this._systemSkyline) { + const pool = this.system.layout.renderer.layout!.skylinePool; + this._systemSkyline = new StaffSystemSkyline( + this.staffIndex, + this.system.index, + 0, + Number.MAX_SAFE_INTEGER, + pool + ); + } + return this._systemSkyline; + } + + private _unionBarLocalIntoStaffSkyline(renderer: BarRendererBase): void { + const sky = this.systemSkyline; + const baseX = renderer.x; + const bar = renderer.barLocalSkyline; + const pre = renderer.preBeatLocalSkyline; + const post = renderer.postBeatLocalSkyline; + // Post-beat segments live in post-beat-group-local coords; shift by the + // group's final x (settled by scaleToWidth) before unioning. + const postBaseX = baseX + renderer.postBeatGroupOffset; + + sky.upSky.unionShifted(bar.upSky, baseX); + sky.downSky.unionShifted(bar.downSky, baseX); + sky.upSky.unionShifted(pre.upSky, baseX); + sky.downSky.unionShifted(pre.downSky, baseX); + sky.upSky.unionShifted(post.upSky, postBaseX); + sky.downSky.unionShifted(post.downSky, postBaseX); + } + /** * Performs an early calculation of the expected staff height for the size calculation in the * accolade (e.g. for braces). This typically happens after the first bar renderers were created @@ -234,34 +278,34 @@ export class RenderStaff { this.height = 0; - // 1st pass: let all renderers finalize themselves, this might cause - // changes in the overflows - let needsSecondPass = false; - let topOverflow: number = this.topOverflow; - for (const renderer of this.barRenderers) { - renderer.registerMultiSystemSlurs(this.system.layout!.slurRegistry.getAllContinuations(renderer)); - if (renderer.finalizeRenderer()) { - needsSecondPass = true; - } - this.height = Math.max(this.height, renderer.height); + this.systemSkyline.reset(); + + // Only renderer 0 ever yields continuations; hoist out of the per-renderer loop. + if (this.barRenderers.length > 0) { + this.barRenderers[0].registerMultiSystemSlurs( + this.system.layout!.slurRegistry.getAllContinuations(this.barRenderers[0]) + ); } - // 2nd pass: move renderers to correct position respecting the new overflows - if (needsSecondPass) { - topOverflow = this.topOverflow; - // shift all the renderers to the new position to match required spacing - for (const renderer of this.barRenderers) { - renderer.y = this.topPadding + topOverflow; - } + for (const renderer of this.barRenderers) { + renderer.finalizeOwnedTies(); + renderer.finalizeEffectBandSpans(); - // finalize again (to align ties) - for (const renderer of this.barRenderers) { - renderer.finalizeRenderer(); + if (renderer.tiesDirty) { + renderer.refreshSizes(); + renderer.registerStaffOverflows(); + renderer.clearTiesDirty(); } + if (renderer.height > this.height) { + this.height = renderer.height; + } + this._unionBarLocalIntoStaffSkyline(renderer); } + this.effectPlacement.placeAndApply(); + if (this.height > 0) { - this.height += this.topPadding + topOverflow + this.bottomOverflow + this.bottomPadding; + this.height += this.topPadding + this.topOverflow + this.bottomOverflow + this.bottomPadding; } this.height = Math.ceil(this.height); diff --git a/packages/alphatab/src/rendering/staves/StaffSystem.ts b/packages/alphatab/src/rendering/staves/StaffSystem.ts index 1fa922a1f..27b77f1a2 100644 --- a/packages/alphatab/src/rendering/staves/StaffSystem.ts +++ b/packages/alphatab/src/rendering/staves/StaffSystem.ts @@ -8,6 +8,7 @@ import { TrackNameOrientation, TrackNamePolicy } from '@coderline/alphatab/model/RenderStylesheet'; +import { SimileMark } from '@coderline/alphatab/model/SimileMark'; import { type Track, TrackSubElement } from '@coderline/alphatab/model/Track'; import { NotationElement } from '@coderline/alphatab/NotationSettings'; import { CanvasHelper, type ICanvas, TextAlign, TextBaseline } from '@coderline/alphatab/platform/ICanvas'; @@ -154,7 +155,19 @@ class SimilarInstrumentSystemBracket extends SingleTrackSystemBracket { * @internal */ export class StaffSystem { - private _accoladeSpacingCalculated: boolean = false; + // Single-slot memo for the track-name (measureText-driven) component of + // `accoladeWidth`. -1 means "not yet computed"; >=0 is the cached value. + // The track-name component is determined by inputs (tracks array, stylesheet, + // track-name font, padding) that are stable across a system's lifetime. + private _trackNamesAccoladeContribution: number = -1; + + /** + * Visibility bitset of {@link allStaves} (one bit per staff, MSB first) at + * the time `accoladeWidth` was last fully recomputed. `-1` = uncomputed. + * Used by `_calculateAccoladeSpacing` to skip the full recompute when + * visibility is unchanged. Limit: 53 staves (JS safe-integer range). + */ + private _accoladeVisibilityFingerprint: number = -1; private _brackets: SystemBracket[] = []; private _staffToBracket = new Map(); @@ -372,7 +385,7 @@ export class StaffSystem { if (renderer.isLinkedToPrevious) { result.isLinkedToPrevious = true; } - if (!renderer.canWrap) { + if (bar.simileMark === SimileMark.SecondOfDouble) { result.canWrap = false; } } @@ -453,7 +466,11 @@ export class StaffSystem { let totalContentWidth = 0; for (const mb of this.masterBarsRenderers) { - if (mb.layoutingInfo.computedWithMinDuration > this.minDuration) { + // Only re-apply renderers whose layoutingInfo was actually + // recomputed in this loop. `applyLayoutingInfo` no longer + // short-circuits internally, so the caller gates it. + const wasRecomputed = mb.layoutingInfo.computedWithMinDuration > this.minDuration; + if (wasRecomputed) { mb.layoutingInfo.recomputeSpringConstants(this.minDuration); } @@ -461,7 +478,9 @@ export class StaffSystem { let maxContent = 0; let realWidth = 0; for (const r of mb.renderers) { - r.applyLayoutingInfo(); + if (wasRecomputed) { + r.applyLayoutingInfo(); + } if (r.computedWidth > realWidth) { realWidth = r.computedWidth; } @@ -537,6 +556,12 @@ export class StaffSystem { this.totalBarDisplayScale -= barDisplayScale; this.totalFixedOverhead -= toRemove.maxFixedOverhead; this.totalContentWidth -= toRemove.maxContentWidth; + + // Re-run accolade spacing now that visibility has settled. The + // brace contribution may shrink if a visible staff is no longer + // visible after this revert. + this._calculateAccoladeSpacing(this.layout.renderer.tracks!); + return toRemove; } return null; @@ -583,17 +608,41 @@ export class StaffSystem { private _calculateAccoladeSpacing(tracks: Track[]): void { const settings = this.layout.renderer.settings; - if (!this._accoladeSpacingCalculated) { - this._accoladeSpacingCalculated = true; - this.accoladeWidth = 0; - - const stylesheet = this.layout.renderer.score!.stylesheet; - const hasTrackName = this.layout.renderer.settings.notation.isNotationElementVisible( - NotationElement.TrackNames - ); + // Full recompute only when visibility changes (initial call, + // revertLastBar flipping a staff invisible, or an added bar flipping a + // previously-invisible staff visible). On stable visibility we still + // refresh bracket `width` for paint, but leave `accoladeWidth` / + // `system.width` locked at their first-pass value — the brace + // contribution would otherwise grow with the overflow accumulators that + // `calculateHeightForAccolade` reads. + const visibilityFingerprint = this._computeVisibilityFingerprint(); + if (this._accoladeVisibilityFingerprint === visibilityFingerprint) { + for (const b of this._brackets) { + b.updateCanPaint(); + b.finalizeBracket(settings.display.resources.engravingSettings); + } + return; + } - if (hasTrackName) { + // Successive recomputes must converge — unwind the previous accolade + // contribution from system width totals before re-deriving it. + const prevContribution = this.accoladeWidth; + this.width -= prevContribution; + this.computedWidth -= prevContribution; + this.accoladeWidth = 0; + + const hasTrackName = settings.notation.isNotationElementVisible(NotationElement.TrackNames); + + if (hasTrackName) { + // The track-name component is determined by the tracks array, the + // stylesheet, the track-name font and padding settings — all stable + // within a system's lifetime. Memoize the computed contribution so + // measureText doesn't run on every addBars/revertLastBar invocation. + if (this._trackNamesAccoladeContribution >= 0) { + this.accoladeWidth = this._trackNamesAccoladeContribution; + } else { + const stylesheet = this.layout.renderer.score!.stylesheet; const trackNamePolicy = this.layout.renderer.tracks!.length === 1 ? stylesheet.singleTrackTrackNamePolicy @@ -650,50 +699,80 @@ export class StaffSystem { } } + // Accumulates onto the freshly-zeroed accoladeWidth within + // this invocation; not a cross-call accumulator. this.accoladeWidth += settings.display.systemLabelPaddingLeft; if (hasAnyTrackName) { this.accoladeWidth += settings.display.systemLabelPaddingRight; } } - } - // NOTE: we have a chicken-egg problem when it comes to scaling braces which we try to mitigate here: - // - The brace scales with the height of the system - // - The height of the system depends on the bars which can be fitted - // By taking another bar into the system, the height can grow and by this the width of the brace and then it doesn't fit anymore. - // It is not worth the complexity to align the height and width of the brace. - // So we do a rough approximation of the space needed for the brace based on the staves we have at this point. - // Additional Staff separations caused later are not respected. - // users can mitigate truncation with specfiying a systemLabelPaddingLeft. - - // alternative idea for the future: - // - we could force the brace to the width we initially calculate here so it will not grow beyond that. - // - requires a feature to draw glyphs with a max-width or a horizontal stretch scale - - let currentY: number = 0; - for (const staff of this.allStaves) { - staff.y = currentY; - staff.calculateHeightForAccolade(); - currentY += staff.height; + this._trackNamesAccoladeContribution = this.accoladeWidth; } + } - let braceWidth = 0; - for (const b of this._brackets) { - b.updateCanPaint(); - b.finalizeBracket(settings.display.resources.engravingSettings); - braceWidth = Math.max(braceWidth, b.width); - } + // NOTE: we have a chicken-egg problem when it comes to scaling braces which we try to mitigate here: + // - The brace scales with the height of the system + // - The height of the system depends on the bars which can be fitted + // By taking another bar into the system, the height can grow and by this the width of the brace and then it doesn't fit anymore. + // It is not worth the complexity to align the height and width of the brace. + // So we do a rough approximation of the space needed for the brace based on the staves we have at this point. + // Additional Staff separations caused later are not respected. + // users can mitigate truncation with specfiying a systemLabelPaddingLeft. + + // alternative idea for the future: + // - we could force the brace to the width we initially calculate here so it will not grow beyond that. + // - requires a feature to draw glyphs with a max-width or a horizontal stretch scale + + // currentY is a local accumulator that resets to 0 here; staff.y is an + // assignment (not accumulation), and staff.calculateHeightForAccolade() + // is itself idempotent — so this block is safe under repeated invocation. + let currentY: number = 0; + for (const staff of this.allStaves) { + staff.y = currentY; + staff.calculateHeightForAccolade(); + currentY += staff.height; + } - this.accoladeWidth += braceWidth; + // Brace width depends on per-staff visibility (via updateCanPaint), so + // it must be recomputed when visibility changes — catches the + // accolade-shrink case when a revert hides a staff. + let braceWidth = 0; + for (const b of this._brackets) { + b.updateCanPaint(); + b.finalizeBracket(settings.display.resources.engravingSettings); + braceWidth = Math.max(braceWidth, b.width); + } - this.width += this.accoladeWidth; - this.computedWidth += this.accoladeWidth; - } else { - for (const b of this._brackets) { - b.updateCanPaint(); - b.finalizeBracket(settings.display.resources.engravingSettings); - } + this.accoladeWidth += braceWidth; + + this.width += this.accoladeWidth; + this.computedWidth += this.accoladeWidth; + + this._accoladeVisibilityFingerprint = visibilityFingerprint; + } + + /** + * Resets cross-bar staff state in {@link RenderStaff._sharedLayoutData} + * before `alignGlyphs` runs, so the max-of-idempotent + * `EffectInfo.onAlignGlyphs` writers start from a clean slate each cycle. + * Per-revert resets are handled separately by {@link RenderStaff.revertLastBar}. + */ + public resetAllStavesSharedLayoutData(): void { + for (const s of this.allStaves) { + s.resetSharedLayoutData(); + } + } + + private _computeVisibilityFingerprint(): number { + // Pack one bit per staff into a numeric bitset. `* 2` (not `<< 1`) so + // we stay in JS double-precision safe-integer range; bitwise ops would + // cap at 32 bits. See `_accoladeVisibilityFingerprint` for the limit. + let fingerprint = 0; + for (const s of this.allStaves) { + fingerprint = fingerprint * 2 + (s.isVisible ? 1 : 0); } + return fingerprint; } private _getStaffTrackGroup(track: Track): StaffTrackGroup | null { diff --git a/packages/alphatab/src/rendering/utils/BeamingHelper.ts b/packages/alphatab/src/rendering/utils/BeamingHelper.ts index 17933a77a..67a80e47f 100644 --- a/packages/alphatab/src/rendering/utils/BeamingHelper.ts +++ b/packages/alphatab/src/rendering/utils/BeamingHelper.ts @@ -8,9 +8,8 @@ import type { Note } from '@coderline/alphatab/model/Note'; import type { Staff } from '@coderline/alphatab/model/Staff'; import type { Voice } from '@coderline/alphatab/model/Voice'; import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; -import { BeatXPosition } from '@coderline/alphatab/rendering/BeatXPosition'; import { AccidentalHelper } from '@coderline/alphatab/rendering/utils/AccidentalHelper'; -import type { BeamDirection } from '@coderline/alphatab/rendering/utils/BeamDirection'; +import { BeamDirection } from '@coderline/alphatab/rendering/utils/BeamDirection'; import type { BeamingRuleLookup } from '@coderline/alphatab/rendering/utils/BeamingRuleLookup'; /** @@ -106,12 +105,14 @@ export class BeamingHelper { this._beamingRuleLookup = beamingRuleLookup; } - public alignWithBeats() { - for (const v of this.drawingInfos.values()) { - v.startX = this._renderer.getBeatX(v.startBeat!, BeatXPosition.Stem); - v.endX = this._renderer.getBeatX(v.endBeat!, BeatXPosition.Stem); - this.drawingInfos.clear(); - } + /** + * Invalidates cached drawing infos. The emit path (`emitHelperSkyline` → + * `_computeBeamingBounds` → `ensureBeamDrawingInfo`) repopulates them + * with post-spring X. + */ + public invalidateDrawingInfos() { + this._drawingInfoUpValid = false; + this._drawingInfoDownValid = false; } public finish(): void { @@ -312,5 +313,35 @@ export class BeamingHelper { return this.highestNoteInHelper!.beat; } - public drawingInfos: Map = new Map(); + /** + * Per-direction beam drawing info cache. BeamDirection is a 2-value enum + * (Up=0, Down=1) so we use two pre-allocated `BeamingHelperDrawInfo` + * instances plus paired `*Valid: boolean` flags. The slot is reused + * across cycles; `*Valid=false` means callers must re-initialize before + * reading. This avoids the per-cycle Map allocation and lookup cost on + * the hot beam-paint path. The "paired Valid + non-null T" pattern + * follows {@link BarTempoGlyph} — `T | null` does not transpile cleanly + * to C# (nullable struct/class semantics diverge), but plain fields plus + * a boolean do. + */ + private readonly _drawingInfoUp: BeamingHelperDrawInfo = new BeamingHelperDrawInfo(); + private readonly _drawingInfoDown: BeamingHelperDrawInfo = new BeamingHelperDrawInfo(); + private _drawingInfoUpValid: boolean = false; + private _drawingInfoDownValid: boolean = false; + + public getDrawingInfo(direction: BeamDirection): BeamingHelperDrawInfo { + return direction === BeamDirection.Up ? this._drawingInfoUp : this._drawingInfoDown; + } + + public hasDrawingInfo(direction: BeamDirection): boolean { + return direction === BeamDirection.Up ? this._drawingInfoUpValid : this._drawingInfoDownValid; + } + + public markDrawingInfoValid(direction: BeamDirection): void { + if (direction === BeamDirection.Up) { + this._drawingInfoUpValid = true; + } else { + this._drawingInfoDownValid = true; + } + } } diff --git a/packages/alphatab/test-data/guitarpro8/transposition-tonality-a.png b/packages/alphatab/test-data/guitarpro8/transposition-tonality-a.png index 73cab5c2a..35fff4cc6 100644 Binary files a/packages/alphatab/test-data/guitarpro8/transposition-tonality-a.png and b/packages/alphatab/test-data/guitarpro8/transposition-tonality-a.png differ diff --git a/packages/alphatab/test-data/guitarpro8/transposition-tonality-ab.png b/packages/alphatab/test-data/guitarpro8/transposition-tonality-ab.png index 9d6aa2fcf..3aee28de4 100644 Binary files a/packages/alphatab/test-data/guitarpro8/transposition-tonality-ab.png and b/packages/alphatab/test-data/guitarpro8/transposition-tonality-ab.png differ diff --git a/packages/alphatab/test-data/guitarpro8/transposition-tonality-b.png b/packages/alphatab/test-data/guitarpro8/transposition-tonality-b.png index b6102fd8d..37e647ce0 100644 Binary files a/packages/alphatab/test-data/guitarpro8/transposition-tonality-b.png and b/packages/alphatab/test-data/guitarpro8/transposition-tonality-b.png differ diff --git a/packages/alphatab/test-data/guitarpro8/transposition-tonality-bb.png b/packages/alphatab/test-data/guitarpro8/transposition-tonality-bb.png index 86d63f1ae..75ec1cd32 100644 Binary files a/packages/alphatab/test-data/guitarpro8/transposition-tonality-bb.png and b/packages/alphatab/test-data/guitarpro8/transposition-tonality-bb.png differ diff --git a/packages/alphatab/test-data/guitarpro8/transposition-tonality-c.png b/packages/alphatab/test-data/guitarpro8/transposition-tonality-c.png index ce395d829..597156cea 100644 Binary files a/packages/alphatab/test-data/guitarpro8/transposition-tonality-c.png and b/packages/alphatab/test-data/guitarpro8/transposition-tonality-c.png differ diff --git a/packages/alphatab/test-data/guitarpro8/transposition-tonality-cb.png b/packages/alphatab/test-data/guitarpro8/transposition-tonality-cb.png index 3249afb2a..49099e37a 100644 Binary files a/packages/alphatab/test-data/guitarpro8/transposition-tonality-cb.png and b/packages/alphatab/test-data/guitarpro8/transposition-tonality-cb.png differ diff --git a/packages/alphatab/test-data/guitarpro8/transposition-tonality-csharp.png b/packages/alphatab/test-data/guitarpro8/transposition-tonality-csharp.png index a8102f4b8..e1523c960 100644 Binary files a/packages/alphatab/test-data/guitarpro8/transposition-tonality-csharp.png and b/packages/alphatab/test-data/guitarpro8/transposition-tonality-csharp.png differ diff --git a/packages/alphatab/test-data/guitarpro8/transposition-tonality-d.png b/packages/alphatab/test-data/guitarpro8/transposition-tonality-d.png index de9a3e19e..0756c9987 100644 Binary files a/packages/alphatab/test-data/guitarpro8/transposition-tonality-d.png and b/packages/alphatab/test-data/guitarpro8/transposition-tonality-d.png differ diff --git a/packages/alphatab/test-data/guitarpro8/transposition-tonality-db.png b/packages/alphatab/test-data/guitarpro8/transposition-tonality-db.png index 86b58fc52..b424d91c7 100644 Binary files a/packages/alphatab/test-data/guitarpro8/transposition-tonality-db.png and b/packages/alphatab/test-data/guitarpro8/transposition-tonality-db.png differ diff --git a/packages/alphatab/test-data/guitarpro8/transposition-tonality-e.png b/packages/alphatab/test-data/guitarpro8/transposition-tonality-e.png index 4b4399d2d..092e4469b 100644 Binary files a/packages/alphatab/test-data/guitarpro8/transposition-tonality-e.png and b/packages/alphatab/test-data/guitarpro8/transposition-tonality-e.png differ diff --git a/packages/alphatab/test-data/guitarpro8/transposition-tonality-eb.png b/packages/alphatab/test-data/guitarpro8/transposition-tonality-eb.png index e2416bba5..3496a591a 100644 Binary files a/packages/alphatab/test-data/guitarpro8/transposition-tonality-eb.png and b/packages/alphatab/test-data/guitarpro8/transposition-tonality-eb.png differ diff --git a/packages/alphatab/test-data/guitarpro8/transposition-tonality-f.png b/packages/alphatab/test-data/guitarpro8/transposition-tonality-f.png index cd1ba9aed..e804a8fe2 100644 Binary files a/packages/alphatab/test-data/guitarpro8/transposition-tonality-f.png and b/packages/alphatab/test-data/guitarpro8/transposition-tonality-f.png differ diff --git a/packages/alphatab/test-data/guitarpro8/transposition-tonality-fsharp.png b/packages/alphatab/test-data/guitarpro8/transposition-tonality-fsharp.png index 284e8b738..3cb048ae3 100644 Binary files a/packages/alphatab/test-data/guitarpro8/transposition-tonality-fsharp.png and b/packages/alphatab/test-data/guitarpro8/transposition-tonality-fsharp.png differ diff --git a/packages/alphatab/test-data/guitarpro8/transposition-tonality-g.png b/packages/alphatab/test-data/guitarpro8/transposition-tonality-g.png index 84790605a..96642be6e 100644 Binary files a/packages/alphatab/test-data/guitarpro8/transposition-tonality-g.png and b/packages/alphatab/test-data/guitarpro8/transposition-tonality-g.png differ diff --git a/packages/alphatab/test-data/guitarpro8/transposition-tonality-gb.png b/packages/alphatab/test-data/guitarpro8/transposition-tonality-gb.png index e6525fee3..11d3a3ba3 100644 Binary files a/packages/alphatab/test-data/guitarpro8/transposition-tonality-gb.png and b/packages/alphatab/test-data/guitarpro8/transposition-tonality-gb.png differ diff --git a/packages/alphatab/test-data/musicxml-samples/BeetAnGeSample.png b/packages/alphatab/test-data/musicxml-samples/BeetAnGeSample.png index 11680dd98..0d0ea4dd4 100644 Binary files a/packages/alphatab/test-data/musicxml-samples/BeetAnGeSample.png and b/packages/alphatab/test-data/musicxml-samples/BeetAnGeSample.png differ diff --git a/packages/alphatab/test-data/musicxml-samples/Binchois.png b/packages/alphatab/test-data/musicxml-samples/Binchois.png index 98c59937a..25e43f4f9 100644 Binary files a/packages/alphatab/test-data/musicxml-samples/Binchois.png and b/packages/alphatab/test-data/musicxml-samples/Binchois.png differ diff --git a/packages/alphatab/test-data/musicxml-samples/BrahWiMeSample.png b/packages/alphatab/test-data/musicxml-samples/BrahWiMeSample.png index a4258a4db..0413de421 100644 Binary files a/packages/alphatab/test-data/musicxml-samples/BrahWiMeSample.png and b/packages/alphatab/test-data/musicxml-samples/BrahWiMeSample.png differ diff --git a/packages/alphatab/test-data/musicxml-samples/BrookeWestSample.png b/packages/alphatab/test-data/musicxml-samples/BrookeWestSample.png index e8a309eeb..f9febd1dc 100644 Binary files a/packages/alphatab/test-data/musicxml-samples/BrookeWestSample.png and b/packages/alphatab/test-data/musicxml-samples/BrookeWestSample.png differ diff --git a/packages/alphatab/test-data/musicxml-samples/Chant.png b/packages/alphatab/test-data/musicxml-samples/Chant.png index f87c5a216..cec161174 100644 Binary files a/packages/alphatab/test-data/musicxml-samples/Chant.png and b/packages/alphatab/test-data/musicxml-samples/Chant.png differ diff --git a/packages/alphatab/test-data/musicxml-samples/DebuMandSample.png b/packages/alphatab/test-data/musicxml-samples/DebuMandSample.png index 743b5ba81..1f3e35330 100644 Binary files a/packages/alphatab/test-data/musicxml-samples/DebuMandSample.png and b/packages/alphatab/test-data/musicxml-samples/DebuMandSample.png differ diff --git a/packages/alphatab/test-data/musicxml-samples/Dichterliebe01.png b/packages/alphatab/test-data/musicxml-samples/Dichterliebe01.png index 1d8480bd6..711bfe0ea 100644 Binary files a/packages/alphatab/test-data/musicxml-samples/Dichterliebe01.png and b/packages/alphatab/test-data/musicxml-samples/Dichterliebe01.png differ diff --git a/packages/alphatab/test-data/musicxml-samples/Echigo.png b/packages/alphatab/test-data/musicxml-samples/Echigo.png index ebefe64d5..1986950e8 100644 Binary files a/packages/alphatab/test-data/musicxml-samples/Echigo.png and b/packages/alphatab/test-data/musicxml-samples/Echigo.png differ diff --git a/packages/alphatab/test-data/musicxml-samples/FaurReveSample.png b/packages/alphatab/test-data/musicxml-samples/FaurReveSample.png index 887ced6d4..da5f61505 100644 Binary files a/packages/alphatab/test-data/musicxml-samples/FaurReveSample.png and b/packages/alphatab/test-data/musicxml-samples/FaurReveSample.png differ diff --git a/packages/alphatab/test-data/musicxml-samples/MahlFaGe4Sample.png b/packages/alphatab/test-data/musicxml-samples/MahlFaGe4Sample.png index 764df91de..feb6e0e3e 100644 Binary files a/packages/alphatab/test-data/musicxml-samples/MahlFaGe4Sample.png and b/packages/alphatab/test-data/musicxml-samples/MahlFaGe4Sample.png differ diff --git a/packages/alphatab/test-data/musicxml-samples/MozaChloSample.png b/packages/alphatab/test-data/musicxml-samples/MozaChloSample.png index 3aeb1edbe..416ad085d 100644 Binary files a/packages/alphatab/test-data/musicxml-samples/MozaChloSample.png and b/packages/alphatab/test-data/musicxml-samples/MozaChloSample.png differ diff --git a/packages/alphatab/test-data/musicxml-samples/MozaVeilSample.png b/packages/alphatab/test-data/musicxml-samples/MozaVeilSample.png index 39ac07349..9a0ee2142 100644 Binary files a/packages/alphatab/test-data/musicxml-samples/MozaVeilSample.png and b/packages/alphatab/test-data/musicxml-samples/MozaVeilSample.png differ diff --git a/packages/alphatab/test-data/musicxml-samples/MozartTrio.png b/packages/alphatab/test-data/musicxml-samples/MozartTrio.png index 5081ac08b..fe7b203d5 100644 Binary files a/packages/alphatab/test-data/musicxml-samples/MozartTrio.png and b/packages/alphatab/test-data/musicxml-samples/MozartTrio.png differ diff --git a/packages/alphatab/test-data/musicxml-samples/Saltarello.png b/packages/alphatab/test-data/musicxml-samples/Saltarello.png index 4684f76cb..701f0faf8 100644 Binary files a/packages/alphatab/test-data/musicxml-samples/Saltarello.png and b/packages/alphatab/test-data/musicxml-samples/Saltarello.png differ diff --git a/packages/alphatab/test-data/musicxml-samples/SchbAvMaSample.png b/packages/alphatab/test-data/musicxml-samples/SchbAvMaSample.png index 8e03eff88..ba6652cee 100644 Binary files a/packages/alphatab/test-data/musicxml-samples/SchbAvMaSample.png and b/packages/alphatab/test-data/musicxml-samples/SchbAvMaSample.png differ diff --git a/packages/alphatab/test-data/musicxml-samples/Telemann.png b/packages/alphatab/test-data/musicxml-samples/Telemann.png index 166555266..2a520172b 100644 Binary files a/packages/alphatab/test-data/musicxml-samples/Telemann.png and b/packages/alphatab/test-data/musicxml-samples/Telemann.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/01c-Pitches-NoVoiceElement.png b/packages/alphatab/test-data/musicxml-testsuite/01c-Pitches-NoVoiceElement.png index 6b5920f81..4f50c9214 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/01c-Pitches-NoVoiceElement.png and b/packages/alphatab/test-data/musicxml-testsuite/01c-Pitches-NoVoiceElement.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/13b-KeySignatures-ChurchModes.png b/packages/alphatab/test-data/musicxml-testsuite/13b-KeySignatures-ChurchModes.png index ed8af15b6..d2104a8f3 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/13b-KeySignatures-ChurchModes.png and b/packages/alphatab/test-data/musicxml-testsuite/13b-KeySignatures-ChurchModes.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/21d-Chords-SchubertStabatMater.png b/packages/alphatab/test-data/musicxml-testsuite/21d-Chords-SchubertStabatMater.png index 7188c59ff..799d034c2 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/21d-Chords-SchubertStabatMater.png and b/packages/alphatab/test-data/musicxml-testsuite/21d-Chords-SchubertStabatMater.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/21f-Chord-ElementInBetween.png b/packages/alphatab/test-data/musicxml-testsuite/21f-Chord-ElementInBetween.png index 9d2820959..e9560babe 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/21f-Chord-ElementInBetween.png and b/packages/alphatab/test-data/musicxml-testsuite/21f-Chord-ElementInBetween.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/22a-Noteheads.png b/packages/alphatab/test-data/musicxml-testsuite/22a-Noteheads.png index f0081de94..46e9bcd9e 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/22a-Noteheads.png and b/packages/alphatab/test-data/musicxml-testsuite/22a-Noteheads.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/22b-Staff-Notestyles.png b/packages/alphatab/test-data/musicxml-testsuite/22b-Staff-Notestyles.png index 1faf1f230..1e200dfef 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/22b-Staff-Notestyles.png and b/packages/alphatab/test-data/musicxml-testsuite/22b-Staff-Notestyles.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/22c-Noteheads-Chords.png b/packages/alphatab/test-data/musicxml-testsuite/22c-Noteheads-Chords.png index 5d0ba7ff2..6ae46e8c7 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/22c-Noteheads-Chords.png and b/packages/alphatab/test-data/musicxml-testsuite/22c-Noteheads-Chords.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/23e-Tuplets-Tremolo.png b/packages/alphatab/test-data/musicxml-testsuite/23e-Tuplets-Tremolo.png index 671b59043..d5962b109 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/23e-Tuplets-Tremolo.png and b/packages/alphatab/test-data/musicxml-testsuite/23e-Tuplets-Tremolo.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/24g-GraceNote-Dynamics.png b/packages/alphatab/test-data/musicxml-testsuite/24g-GraceNote-Dynamics.png index e579ed5d5..09b5807e4 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/24g-GraceNote-Dynamics.png and b/packages/alphatab/test-data/musicxml-testsuite/24g-GraceNote-Dynamics.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/24h-GraceNote-Simultaneous.png b/packages/alphatab/test-data/musicxml-testsuite/24h-GraceNote-Simultaneous.png index 0a94dc0b9..2bdac1a42 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/24h-GraceNote-Simultaneous.png and b/packages/alphatab/test-data/musicxml-testsuite/24h-GraceNote-Simultaneous.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/31a-Directions.png b/packages/alphatab/test-data/musicxml-testsuite/31a-Directions.png index 7b3d17e63..f49963a0f 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/31a-Directions.png and b/packages/alphatab/test-data/musicxml-testsuite/31a-Directions.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/31b-Directions-Order.png b/packages/alphatab/test-data/musicxml-testsuite/31b-Directions-Order.png index b0e4259f4..3ed858718 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/31b-Directions-Order.png and b/packages/alphatab/test-data/musicxml-testsuite/31b-Directions-Order.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/31c-MetronomeMarks.png b/packages/alphatab/test-data/musicxml-testsuite/31c-MetronomeMarks.png index 8e3d4f127..9eeb5f6da 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/31c-MetronomeMarks.png and b/packages/alphatab/test-data/musicxml-testsuite/31c-MetronomeMarks.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/31d-Directions-Compounds.png b/packages/alphatab/test-data/musicxml-testsuite/31d-Directions-Compounds.png index 96fe71ee5..78082e48e 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/31d-Directions-Compounds.png and b/packages/alphatab/test-data/musicxml-testsuite/31d-Directions-Compounds.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/32a-Notations.png b/packages/alphatab/test-data/musicxml-testsuite/32a-Notations.png index a00ba3205..7f3070c5f 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-data/musicxml-testsuite/32b-Articulations-Texts.png b/packages/alphatab/test-data/musicxml-testsuite/32b-Articulations-Texts.png index a71377b7e..b2c76f1b1 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/32b-Articulations-Texts.png and b/packages/alphatab/test-data/musicxml-testsuite/32b-Articulations-Texts.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/32d-Arpeggio.png b/packages/alphatab/test-data/musicxml-testsuite/32d-Arpeggio.png index 0fcb2cb12..fc8a5e89f 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/32d-Arpeggio.png and b/packages/alphatab/test-data/musicxml-testsuite/32d-Arpeggio.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/33a-Spanners.png b/packages/alphatab/test-data/musicxml-testsuite/33a-Spanners.png index 8e0d7c4ca..4b7a7bbea 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/33a-Spanners.png and b/packages/alphatab/test-data/musicxml-testsuite/33a-Spanners.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/33da-Spanners-OctaveShifts-before.png b/packages/alphatab/test-data/musicxml-testsuite/33da-Spanners-OctaveShifts-before.png index 5fa9e3a92..b09dc95fc 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/33da-Spanners-OctaveShifts-before.png and b/packages/alphatab/test-data/musicxml-testsuite/33da-Spanners-OctaveShifts-before.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/33db-Spanners-OctaveShifts-after.png b/packages/alphatab/test-data/musicxml-testsuite/33db-Spanners-OctaveShifts-after.png index 22497714f..3990eeff7 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/33db-Spanners-OctaveShifts-after.png and b/packages/alphatab/test-data/musicxml-testsuite/33db-Spanners-OctaveShifts-after.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/33f-Trill-EndingOnGraceNote.png b/packages/alphatab/test-data/musicxml-testsuite/33f-Trill-EndingOnGraceNote.png index b5c660870..9aa553b7f 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/33f-Trill-EndingOnGraceNote.png and b/packages/alphatab/test-data/musicxml-testsuite/33f-Trill-EndingOnGraceNote.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/33h-Spanners-Glissando.png b/packages/alphatab/test-data/musicxml-testsuite/33h-Spanners-Glissando.png index 5a19f5e29..eead07420 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/33h-Spanners-Glissando.png and b/packages/alphatab/test-data/musicxml-testsuite/33h-Spanners-Glissando.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/33i-Ties-NotEnded.png b/packages/alphatab/test-data/musicxml-testsuite/33i-Ties-NotEnded.png index a383c7b2b..7074bf51a 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/33i-Ties-NotEnded.png and b/packages/alphatab/test-data/musicxml-testsuite/33i-Ties-NotEnded.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/34a-Print-Object-Spanners.png b/packages/alphatab/test-data/musicxml-testsuite/34a-Print-Object-Spanners.png index 95fca954b..71c2489d7 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/34a-Print-Object-Spanners.png and b/packages/alphatab/test-data/musicxml-testsuite/34a-Print-Object-Spanners.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/34b-Colors.png b/packages/alphatab/test-data/musicxml-testsuite/34b-Colors.png index ef1854676..d68a0af55 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/34b-Colors.png and b/packages/alphatab/test-data/musicxml-testsuite/34b-Colors.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/34c-Font-Size.png b/packages/alphatab/test-data/musicxml-testsuite/34c-Font-Size.png index 2bd905f44..4cdd4fb02 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/34c-Font-Size.png and b/packages/alphatab/test-data/musicxml-testsuite/34c-Font-Size.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/42a-MultiVoice-TwoVoicesOnStaff-Lyrics.png b/packages/alphatab/test-data/musicxml-testsuite/42a-MultiVoice-TwoVoicesOnStaff-Lyrics.png index c7c77389f..c4d28e679 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/42a-MultiVoice-TwoVoicesOnStaff-Lyrics.png and b/packages/alphatab/test-data/musicxml-testsuite/42a-MultiVoice-TwoVoicesOnStaff-Lyrics.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/43e-Multistaff-ClefDynamics.png b/packages/alphatab/test-data/musicxml-testsuite/43e-Multistaff-ClefDynamics.png index 02611b7b5..80061528f 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/43e-Multistaff-ClefDynamics.png and b/packages/alphatab/test-data/musicxml-testsuite/43e-Multistaff-ClefDynamics.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/43f-MultiStaff-Lyrics.png b/packages/alphatab/test-data/musicxml-testsuite/43f-MultiStaff-Lyrics.png index 6f21220a2..302fd9180 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/43f-MultiStaff-Lyrics.png and b/packages/alphatab/test-data/musicxml-testsuite/43f-MultiStaff-Lyrics.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/45b-RepeatWithAlternatives.png b/packages/alphatab/test-data/musicxml-testsuite/45b-RepeatWithAlternatives.png index 25f256c4e..5c1d9f74e 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/45b-RepeatWithAlternatives.png and b/packages/alphatab/test-data/musicxml-testsuite/45b-RepeatWithAlternatives.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/45d-Repeats-MultipleEndings.png b/packages/alphatab/test-data/musicxml-testsuite/45d-Repeats-MultipleEndings.png index 67f4aef4f..08481188f 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/45d-Repeats-MultipleEndings.png and b/packages/alphatab/test-data/musicxml-testsuite/45d-Repeats-MultipleEndings.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/45e-Repeats-Combination.png b/packages/alphatab/test-data/musicxml-testsuite/45e-Repeats-Combination.png index 573bd1c7c..32730ce14 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/45e-Repeats-Combination.png and b/packages/alphatab/test-data/musicxml-testsuite/45e-Repeats-Combination.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/45f-Repeats-InvalidEndings.png b/packages/alphatab/test-data/musicxml-testsuite/45f-Repeats-InvalidEndings.png index 517656553..acd22d4e9 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/45f-Repeats-InvalidEndings.png and b/packages/alphatab/test-data/musicxml-testsuite/45f-Repeats-InvalidEndings.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/45i-Repeats-Nested.png b/packages/alphatab/test-data/musicxml-testsuite/45i-Repeats-Nested.png index 75cd736ab..35c7b13cd 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/45i-Repeats-Nested.png and b/packages/alphatab/test-data/musicxml-testsuite/45i-Repeats-Nested.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/46g-PickupMeasure-Chordnames-FiguredBass.png b/packages/alphatab/test-data/musicxml-testsuite/46g-PickupMeasure-Chordnames-FiguredBass.png index 08e19a3b3..776441490 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/46g-PickupMeasure-Chordnames-FiguredBass.png and b/packages/alphatab/test-data/musicxml-testsuite/46g-PickupMeasure-Chordnames-FiguredBass.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/52a-PageLayout.png b/packages/alphatab/test-data/musicxml-testsuite/52a-PageLayout.png index e43de98fb..c5a66327e 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/52a-PageLayout.png and b/packages/alphatab/test-data/musicxml-testsuite/52a-PageLayout.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/61a-Lyrics.png b/packages/alphatab/test-data/musicxml-testsuite/61a-Lyrics.png index 06929b92f..afea7c0c8 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/61a-Lyrics.png and b/packages/alphatab/test-data/musicxml-testsuite/61a-Lyrics.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/61b-MultipleLyrics.png b/packages/alphatab/test-data/musicxml-testsuite/61b-MultipleLyrics.png index 1df1f587a..54b56013c 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/61b-MultipleLyrics.png and b/packages/alphatab/test-data/musicxml-testsuite/61b-MultipleLyrics.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/61c-Lyrics-Pianostaff.png b/packages/alphatab/test-data/musicxml-testsuite/61c-Lyrics-Pianostaff.png index 141b3854b..b267f1a84 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/61c-Lyrics-Pianostaff.png and b/packages/alphatab/test-data/musicxml-testsuite/61c-Lyrics-Pianostaff.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/61d-Lyrics-Melisma.png b/packages/alphatab/test-data/musicxml-testsuite/61d-Lyrics-Melisma.png index 04387f1d7..49cc8d3d5 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/61d-Lyrics-Melisma.png and b/packages/alphatab/test-data/musicxml-testsuite/61d-Lyrics-Melisma.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/61e-Lyrics-Chords.png b/packages/alphatab/test-data/musicxml-testsuite/61e-Lyrics-Chords.png index c29c5f986..f8c17d6cf 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/61e-Lyrics-Chords.png and b/packages/alphatab/test-data/musicxml-testsuite/61e-Lyrics-Chords.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/61f-Lyrics-GracedNotes.png b/packages/alphatab/test-data/musicxml-testsuite/61f-Lyrics-GracedNotes.png index e759ea2b1..986e83285 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/61f-Lyrics-GracedNotes.png and b/packages/alphatab/test-data/musicxml-testsuite/61f-Lyrics-GracedNotes.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/61g-Lyrics-NameNumber.png b/packages/alphatab/test-data/musicxml-testsuite/61g-Lyrics-NameNumber.png index 02d30df63..746dcba91 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/61g-Lyrics-NameNumber.png and b/packages/alphatab/test-data/musicxml-testsuite/61g-Lyrics-NameNumber.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/61h-Lyrics-BeamsMelismata.png b/packages/alphatab/test-data/musicxml-testsuite/61h-Lyrics-BeamsMelismata.png index e7487ec90..4934eee8f 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/61h-Lyrics-BeamsMelismata.png and b/packages/alphatab/test-data/musicxml-testsuite/61h-Lyrics-BeamsMelismata.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/61i-Lyrics-Chords.png b/packages/alphatab/test-data/musicxml-testsuite/61i-Lyrics-Chords.png index 0329cba0e..c967199d7 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/61i-Lyrics-Chords.png and b/packages/alphatab/test-data/musicxml-testsuite/61i-Lyrics-Chords.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/61j-Lyrics-Elisions.png b/packages/alphatab/test-data/musicxml-testsuite/61j-Lyrics-Elisions.png index af978c8fa..673e93ac9 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/61j-Lyrics-Elisions.png and b/packages/alphatab/test-data/musicxml-testsuite/61j-Lyrics-Elisions.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/61k-Lyrics-SpannersExtenders.png b/packages/alphatab/test-data/musicxml-testsuite/61k-Lyrics-SpannersExtenders.png index b4b22b841..5d97daf9d 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/61k-Lyrics-SpannersExtenders.png and b/packages/alphatab/test-data/musicxml-testsuite/61k-Lyrics-SpannersExtenders.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/71a-Chordnames.png b/packages/alphatab/test-data/musicxml-testsuite/71a-Chordnames.png index 718e45edc..6e982c82d 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/71a-Chordnames.png and b/packages/alphatab/test-data/musicxml-testsuite/71a-Chordnames.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/71c-ChordsFrets.png b/packages/alphatab/test-data/musicxml-testsuite/71c-ChordsFrets.png index ed0f43372..b95cb75af 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/71c-ChordsFrets.png and b/packages/alphatab/test-data/musicxml-testsuite/71c-ChordsFrets.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/71d-ChordsFrets-Multistaff.png b/packages/alphatab/test-data/musicxml-testsuite/71d-ChordsFrets-Multistaff.png index 1838f22b7..2b9765c72 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/71d-ChordsFrets-Multistaff.png and b/packages/alphatab/test-data/musicxml-testsuite/71d-ChordsFrets-Multistaff.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/71f-AllChordTypes.png b/packages/alphatab/test-data/musicxml-testsuite/71f-AllChordTypes.png index b3246dc73..da286b449 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/71f-AllChordTypes.png and b/packages/alphatab/test-data/musicxml-testsuite/71f-AllChordTypes.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/71g-MultipleChordnames.png b/packages/alphatab/test-data/musicxml-testsuite/71g-MultipleChordnames.png index ceaa94f50..4f5d6eae2 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/71g-MultipleChordnames.png and b/packages/alphatab/test-data/musicxml-testsuite/71g-MultipleChordnames.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/75a-AccordionRegistrations.png b/packages/alphatab/test-data/musicxml-testsuite/75a-AccordionRegistrations.png index bdb71a216..9aa158195 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/75a-AccordionRegistrations.png and b/packages/alphatab/test-data/musicxml-testsuite/75a-AccordionRegistrations.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/99b-Lyrics-BeamsMelismata-IgnoreBeams.png b/packages/alphatab/test-data/musicxml-testsuite/99b-Lyrics-BeamsMelismata-IgnoreBeams.png index e7487ec90..4934eee8f 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/99b-Lyrics-BeamsMelismata-IgnoreBeams.png and b/packages/alphatab/test-data/musicxml-testsuite/99b-Lyrics-BeamsMelismata-IgnoreBeams.png differ diff --git a/packages/alphatab/test-data/musicxml3/chord-diagram.png b/packages/alphatab/test-data/musicxml3/chord-diagram.png index 23edfb56b..6ac81b6fd 100644 Binary files a/packages/alphatab/test-data/musicxml3/chord-diagram.png and b/packages/alphatab/test-data/musicxml3/chord-diagram.png differ diff --git a/packages/alphatab/test-data/musicxml3/first-bar-tempo.png b/packages/alphatab/test-data/musicxml3/first-bar-tempo.png index 6a4ec528a..9af50c40b 100644 Binary files a/packages/alphatab/test-data/musicxml3/first-bar-tempo.png and b/packages/alphatab/test-data/musicxml3/first-bar-tempo.png differ diff --git a/packages/alphatab/test-data/musicxml3/tie-destination.png b/packages/alphatab/test-data/musicxml3/tie-destination.png index 0a7d6c204..5b5c87570 100644 Binary files a/packages/alphatab/test-data/musicxml3/tie-destination.png and b/packages/alphatab/test-data/musicxml3/tie-destination.png differ diff --git a/packages/alphatab/test-data/visual-tests/bounds-lookup/onnotes-beat.png b/packages/alphatab/test-data/visual-tests/bounds-lookup/onnotes-beat.png index 1161cfd40..3ccd3aff0 100644 Binary files a/packages/alphatab/test-data/visual-tests/bounds-lookup/onnotes-beat.png and b/packages/alphatab/test-data/visual-tests/bounds-lookup/onnotes-beat.png differ diff --git a/packages/alphatab/test-data/visual-tests/bounds-lookup/real-bar.png b/packages/alphatab/test-data/visual-tests/bounds-lookup/real-bar.png index 598ba0fa4..031f7860a 100644 Binary files a/packages/alphatab/test-data/visual-tests/bounds-lookup/real-bar.png and b/packages/alphatab/test-data/visual-tests/bounds-lookup/real-bar.png differ diff --git a/packages/alphatab/test-data/visual-tests/bounds-lookup/real-beat.png b/packages/alphatab/test-data/visual-tests/bounds-lookup/real-beat.png index 49d2b3362..3fd967a08 100644 Binary files a/packages/alphatab/test-data/visual-tests/bounds-lookup/real-beat.png and b/packages/alphatab/test-data/visual-tests/bounds-lookup/real-beat.png differ diff --git a/packages/alphatab/test-data/visual-tests/bounds-lookup/real-master.png b/packages/alphatab/test-data/visual-tests/bounds-lookup/real-master.png index b89766cc2..2d60bec65 100644 Binary files a/packages/alphatab/test-data/visual-tests/bounds-lookup/real-master.png and b/packages/alphatab/test-data/visual-tests/bounds-lookup/real-master.png differ diff --git a/packages/alphatab/test-data/visual-tests/bounds-lookup/real-note.png b/packages/alphatab/test-data/visual-tests/bounds-lookup/real-note.png index 10b41fb99..b16261e83 100644 Binary files a/packages/alphatab/test-data/visual-tests/bounds-lookup/real-note.png and b/packages/alphatab/test-data/visual-tests/bounds-lookup/real-note.png differ diff --git a/packages/alphatab/test-data/visual-tests/bounds-lookup/real-system.png b/packages/alphatab/test-data/visual-tests/bounds-lookup/real-system.png index 2cb4c4dfa..428955643 100644 Binary files a/packages/alphatab/test-data/visual-tests/bounds-lookup/real-system.png and b/packages/alphatab/test-data/visual-tests/bounds-lookup/real-system.png differ diff --git a/packages/alphatab/test-data/visual-tests/bounds-lookup/visual-bar.png b/packages/alphatab/test-data/visual-tests/bounds-lookup/visual-bar.png index 598ba0fa4..031f7860a 100644 Binary files a/packages/alphatab/test-data/visual-tests/bounds-lookup/visual-bar.png and b/packages/alphatab/test-data/visual-tests/bounds-lookup/visual-bar.png differ diff --git a/packages/alphatab/test-data/visual-tests/bounds-lookup/visual-beat.png b/packages/alphatab/test-data/visual-tests/bounds-lookup/visual-beat.png index f06e90c00..d10f828a6 100644 Binary files a/packages/alphatab/test-data/visual-tests/bounds-lookup/visual-beat.png and b/packages/alphatab/test-data/visual-tests/bounds-lookup/visual-beat.png differ diff --git a/packages/alphatab/test-data/visual-tests/bounds-lookup/visual-master.png b/packages/alphatab/test-data/visual-tests/bounds-lookup/visual-master.png index 0f5d63e6c..8b4867a3a 100644 Binary files a/packages/alphatab/test-data/visual-tests/bounds-lookup/visual-master.png and b/packages/alphatab/test-data/visual-tests/bounds-lookup/visual-master.png differ diff --git a/packages/alphatab/test-data/visual-tests/bounds-lookup/visual-note.png b/packages/alphatab/test-data/visual-tests/bounds-lookup/visual-note.png index 10b41fb99..b16261e83 100644 Binary files a/packages/alphatab/test-data/visual-tests/bounds-lookup/visual-note.png and b/packages/alphatab/test-data/visual-tests/bounds-lookup/visual-note.png differ diff --git a/packages/alphatab/test-data/visual-tests/bounds-lookup/visual-system.png b/packages/alphatab/test-data/visual-tests/bounds-lookup/visual-system.png index 5578287ee..8555e9bde 100644 Binary files a/packages/alphatab/test-data/visual-tests/bounds-lookup/visual-system.png and b/packages/alphatab/test-data/visual-tests/bounds-lookup/visual-system.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/accentuations.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/accentuations.png index 50982bf6d..6c912d32e 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/accentuations.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/accentuations.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/barre.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/barre.png index bda15e1a7..6e87acfb5 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/barre.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/barre.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/beat-slash.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/beat-slash.png index bffe3d1dd..0b0200556 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/beat-slash.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/beat-slash.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/beat-tempo-change.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/beat-tempo-change.png index e385c1855..94efed6ee 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/beat-tempo-change.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/beat-tempo-change.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/bend-vibrato-default.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/bend-vibrato-default.png index 0075f08f0..46cd666a1 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/bend-vibrato-default.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/bend-vibrato-default.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/bend-vibrato-songbook.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/bend-vibrato-songbook.png index 8ee181679..c38d115af 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/bend-vibrato-songbook.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/bend-vibrato-songbook.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/bends.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/bends.png index c09c64f66..68f0ecc1d 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/bends.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/bends.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/brush.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/brush.png index a1369016e..a10af4762 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/brush.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/brush.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/chords-duplicates.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/chords-duplicates.png index 561df3ac6..8b72e5bb5 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/chords-duplicates.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/chords-duplicates.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/chords.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/chords.png index 45308b807..3762b17b7 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/chords.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/chords.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/dead-slap.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/dead-slap.png index 4bdc32a6c..8b5a3cef9 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/dead-slap.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/dead-slap.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/directions-simple.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/directions-simple.png index 1a06eab56..a372fc3e2 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/directions-simple.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/directions-simple.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/directions-symbols.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/directions-symbols.png index 9e942b3d1..7af1f9bf6 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/directions-symbols.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/directions-symbols.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/dynamics.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/dynamics.png index b8a5c38ba..1ad73eaf1 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/dynamics.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/dynamics.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/fade-in.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/fade-in.png index fea6e207f..378330295 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/fade-in.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/fade-in.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/fade.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/fade.png index 0c3e27c21..e5826fbfe 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/fade.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/fade.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/fingering-new.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/fingering-new.png index 80a16bef1..b51d830fa 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/fingering-new.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/fingering-new.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/fingering.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/fingering.png index 850ae6d5e..69663340e 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/fingering.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/fingering.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/free-time.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/free-time.png index e5abe0171..f455ad01b 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/free-time.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/free-time.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/golpe-tab.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/golpe-tab.png index 1b55d4467..362a2063a 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/golpe-tab.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/golpe-tab.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/golpe.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/golpe.png index 021e6d961..6d19af8aa 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/golpe.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/golpe.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-asc-then-desc.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-asc-then-desc.png index def200d1b..38662cb1a 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-asc-then-desc.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-asc-then-desc.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at1.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at1.png index 2fb19ff66..5d9905c47 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at1.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at1.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at10.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at10.png index 4a1813295..0ea4ecc78 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at10.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at10.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at11.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at11.png index 9a5255e5d..e2e72d868 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at11.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at11.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at12.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at12.png index d7cefbc39..cac18dfde 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at12.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at12.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at13.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at13.png index 2004ee2ae..624ef1ab4 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at13.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at13.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at14.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at14.png index 763a58feb..6680fbc26 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at14.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at14.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at2.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at2.png index a58d4c608..190231994 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at2.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at2.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at3.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at3.png index ac4426f03..6f2a9822e 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at3.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at3.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at4.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at4.png index 1c9fb1120..a035fa741 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at4.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at4.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at5.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at5.png index 8a2842216..7b1fa96a9 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at5.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at5.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at6.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at6.png index 98790d58f..7b3abef69 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at6.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at6.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at7.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at7.png index 14d4491e7..2f79f6c83 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at7.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at7.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at8.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at8.png index fd2e50864..bed073676 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at8.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at8.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at9.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at9.png index 929a74d4c..0f50bbd24 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at9.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-at9.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-chord-with-chain.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-chord-with-chain.png index b64aecccd..269e79d30 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-chord-with-chain.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-chord-with-chain.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-labels-disabled.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-labels-disabled.png index f13e35db6..15f6f096e 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-labels-disabled.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-labels-disabled.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-mixed-h-slide.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-mixed-h-slide.png index ae8d239e0..c1a4bccae 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-mixed-h-slide.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-mixed-h-slide.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-mixed-slide-h-p.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-mixed-slide-h-p.png index 0ac567bfe..072c5d696 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-mixed-slide-h-p.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-mixed-slide-h-p.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-pull-off-chain.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-pull-off-chain.png index 8ad135117..2a500d5db 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-pull-off-chain.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-pull-off-chain.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-score-only.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-score-only.png index d31249a11..1af88b05d 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-score-only.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-score-only.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-tab-only.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-tab-only.png index c37ee2d66..2248e6d79 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-tab-only.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hopo-arcs-tab-only.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/inscore-chord-diagrams.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/inscore-chord-diagrams.png index c07a31dca..cc0eac1ba 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/inscore-chord-diagrams.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/inscore-chord-diagrams.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/legato.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/legato.png index 2e2af5813..96a6c192c 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/legato.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/legato.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/let-ring.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/let-ring.png index 1478d0738..5009a9114 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/let-ring.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/let-ring.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/lyrics-multi-line-spacing.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/lyrics-multi-line-spacing.png index c479015fa..6d0fc890a 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/lyrics-multi-line-spacing.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/lyrics-multi-line-spacing.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/lyrics-multi-line.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/lyrics-multi-line.png index 25cf7c251..a2ed5d9b3 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/lyrics-multi-line.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/lyrics-multi-line.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/lyrics-single-line.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/lyrics-single-line.png index a03f57cbc..315539280 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/lyrics-single-line.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/lyrics-single-line.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/markers.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/markers.png index 20b4fc51d..cb3bb4cae 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/markers.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/markers.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/ornaments.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/ornaments.png index f97bb128f..3818126bc 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/ornaments.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/ornaments.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/palm-mute.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/palm-mute.png index f8f3f6add..328bb52b3 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/palm-mute.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/palm-mute.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/pick-stroke.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/pick-stroke.png index a225223f5..c6d21c9fe 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/pick-stroke.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/pick-stroke.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/rasgueado.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/rasgueado.png index 6f43748a4..54359b273 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/rasgueado.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/rasgueado.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/slides-line-break.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/slides-line-break.png index 3dad7f512..2b164da6e 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/slides-line-break.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/slides-line-break.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/slides.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/slides.png index 945992e53..42fff5062 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/slides.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/slides.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/string-numbers.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/string-numbers.png index 8dff522bd..a068723fd 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/string-numbers.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/string-numbers.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/sustain-1200.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/sustain-1200.png index 859219e7f..fc1e5f0e7 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/sustain-1200.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/sustain-1200.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/sustain-600.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/sustain-600.png index 1caccd0f8..a0d0057b4 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/sustain-600.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/sustain-600.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/sustain-850.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/sustain-850.png index 7cff47a95..b1ccb147e 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/sustain-850.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/sustain-850.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/tap.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/tap.png index 967418929..72ed73873 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/tap.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/tap.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/tempo-text.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/tempo-text.png index 5612d41a3..a5749004b 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/tempo-text.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/tempo-text.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/tempo.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/tempo.png index 1ef85f6dd..8484e812e 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/tempo.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/tempo.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/text.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/text.png index 8507beb7d..7c4905543 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/text.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/text.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/timer.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/timer.png index 8b0eb2d30..567ff9fe9 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/timer.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/timer.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-bar.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-bar.png index 77991c34f..9b7415a20 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-bar.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-bar.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-flags-bottom.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-flags-bottom.png index d71fbd0bd..5e0b41ced 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-flags-bottom.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-flags-bottom.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-flags-mixed.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-flags-mixed.png index 9466cbb47..abb84ed42 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-flags-mixed.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-flags-mixed.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-flags.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-flags.png index 6433c3946..3f72dac78 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-flags.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-flags.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-picking.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-picking.png index 6a502019c..4167b6149 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-picking.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-picking.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-standard-buzzroll-beams.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-standard-buzzroll-beams.png index 82b310789..29d82d818 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-standard-buzzroll-beams.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-standard-buzzroll-beams.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-standard-buzzroll-flags.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-standard-buzzroll-flags.png index 971aab93c..16d3b8829 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-standard-buzzroll-flags.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-standard-buzzroll-flags.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-standard-default-beams.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-standard-default-beams.png index 79ecd39d9..e87819053 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-standard-default-beams.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-standard-default-beams.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-standard-default-flags.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-standard-default-flags.png index 86f8e7746..cad511274 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-standard-default-flags.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-standard-default-flags.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-tabs-buzzroll-beams.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-tabs-buzzroll-beams.png index d866dc4dd..0175d63f1 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-tabs-buzzroll-beams.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-tabs-buzzroll-beams.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-tabs-buzzroll-flags.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-tabs-buzzroll-flags.png index 9f20f3cc9..def31a494 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-tabs-buzzroll-flags.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-tabs-buzzroll-flags.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-tabs-default-beams.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-tabs-default-beams.png index 7a3f2259a..2d6f8e2f5 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-tabs-default-beams.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-tabs-default-beams.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-tabs-default-flags.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-tabs-default-flags.png index 2d478d018..9d235c204 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-tabs-default-flags.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/tremolo-tabs-default-flags.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/trill.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/trill.png index 3fa25f22a..d1ea751d1 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/trill.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/trill.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/triplet-feel.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/triplet-feel.png index ddf791d44..6fcb1f2bb 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/triplet-feel.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/triplet-feel.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/tuplets-advanced.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/tuplets-advanced.png index c675ce4ee..c07687973 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/tuplets-advanced.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/tuplets-advanced.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/tuplets-huge.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/tuplets-huge.png index 884d778a5..75ea12c78 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/tuplets-huge.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/tuplets-huge.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/tuplets.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/tuplets.png index f463b6c14..0d114670c 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/tuplets.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/tuplets.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/vibrato.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/vibrato.png index ba9e9d670..5c9cd1ddb 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/vibrato.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/vibrato.png differ diff --git a/packages/alphatab/test-data/visual-tests/general/alternate-endings.png b/packages/alphatab/test-data/visual-tests/general/alternate-endings.png index 60eff996a..fd35f16a6 100644 Binary files a/packages/alphatab/test-data/visual-tests/general/alternate-endings.png and b/packages/alphatab/test-data/visual-tests/general/alternate-endings.png differ diff --git a/packages/alphatab/test-data/visual-tests/general/colors-disabled.png b/packages/alphatab/test-data/visual-tests/general/colors-disabled.png index 19e9ad226..2586a7c15 100644 Binary files a/packages/alphatab/test-data/visual-tests/general/colors-disabled.png and b/packages/alphatab/test-data/visual-tests/general/colors-disabled.png differ diff --git a/packages/alphatab/test-data/visual-tests/general/colors.png b/packages/alphatab/test-data/visual-tests/general/colors.png index 248f7247d..58cb1e787 100644 Binary files a/packages/alphatab/test-data/visual-tests/general/colors.png and b/packages/alphatab/test-data/visual-tests/general/colors.png differ diff --git a/packages/alphatab/test-data/visual-tests/general/font-fallback.png b/packages/alphatab/test-data/visual-tests/general/font-fallback.png index 397d799d5..fae8d7e2f 100644 Binary files a/packages/alphatab/test-data/visual-tests/general/font-fallback.png and b/packages/alphatab/test-data/visual-tests/general/font-fallback.png differ diff --git a/packages/alphatab/test-data/visual-tests/general/repeats.png b/packages/alphatab/test-data/visual-tests/general/repeats.png index 5acfcd73a..41bb3d533 100644 Binary files a/packages/alphatab/test-data/visual-tests/general/repeats.png and b/packages/alphatab/test-data/visual-tests/general/repeats.png differ diff --git a/packages/alphatab/test-data/visual-tests/general/song-details.png b/packages/alphatab/test-data/visual-tests/general/song-details.png index adda44fd1..a41eb3e89 100644 Binary files a/packages/alphatab/test-data/visual-tests/general/song-details.png and b/packages/alphatab/test-data/visual-tests/general/song-details.png differ diff --git a/packages/alphatab/test-data/visual-tests/general/tuning.png b/packages/alphatab/test-data/visual-tests/general/tuning.png index aff4f080a..2f88d4cc5 100644 Binary files a/packages/alphatab/test-data/visual-tests/general/tuning.png and b/packages/alphatab/test-data/visual-tests/general/tuning.png differ diff --git a/packages/alphatab/test-data/visual-tests/guitar-tabs/rhythm-with-beams.png b/packages/alphatab/test-data/visual-tests/guitar-tabs/rhythm-with-beams.png index d00d9e033..01226c2ed 100644 Binary files a/packages/alphatab/test-data/visual-tests/guitar-tabs/rhythm-with-beams.png and b/packages/alphatab/test-data/visual-tests/guitar-tabs/rhythm-with-beams.png differ diff --git a/packages/alphatab/test-data/visual-tests/guitar-tabs/rhythm.png b/packages/alphatab/test-data/visual-tests/guitar-tabs/rhythm.png index 0431f90f4..abed3357d 100644 Binary files a/packages/alphatab/test-data/visual-tests/guitar-tabs/rhythm.png and b/packages/alphatab/test-data/visual-tests/guitar-tabs/rhythm.png differ diff --git a/packages/alphatab/test-data/visual-tests/guitar-tabs/string-variations.png b/packages/alphatab/test-data/visual-tests/guitar-tabs/string-variations.png index 0861804be..ebb17dde6 100644 Binary files a/packages/alphatab/test-data/visual-tests/guitar-tabs/string-variations.png and b/packages/alphatab/test-data/visual-tests/guitar-tabs/string-variations.png differ diff --git a/packages/alphatab/test-data/visual-tests/issues/bottom-effect-band.png b/packages/alphatab/test-data/visual-tests/issues/bottom-effect-band.png index 4f784b16c..fda71e076 100644 Binary files a/packages/alphatab/test-data/visual-tests/issues/bottom-effect-band.png and b/packages/alphatab/test-data/visual-tests/issues/bottom-effect-band.png differ diff --git a/packages/alphatab/test-data/visual-tests/issues/let-ring-empty-voice.png b/packages/alphatab/test-data/visual-tests/issues/let-ring-empty-voice.png index f9a8adc89..c2bfed856 100644 Binary files a/packages/alphatab/test-data/visual-tests/issues/let-ring-empty-voice.png and b/packages/alphatab/test-data/visual-tests/issues/let-ring-empty-voice.png differ diff --git a/packages/alphatab/test-data/visual-tests/issues/no-label-padding-left-no-padding.png b/packages/alphatab/test-data/visual-tests/issues/no-label-padding-left-no-padding.png index 470a0a7ab..fcd455b48 100644 Binary files a/packages/alphatab/test-data/visual-tests/issues/no-label-padding-left-no-padding.png and b/packages/alphatab/test-data/visual-tests/issues/no-label-padding-left-no-padding.png differ diff --git a/packages/alphatab/test-data/visual-tests/issues/no-label-padding-left-with-label.png b/packages/alphatab/test-data/visual-tests/issues/no-label-padding-left-with-label.png index eb375c924..964f46ca8 100644 Binary files a/packages/alphatab/test-data/visual-tests/issues/no-label-padding-left-with-label.png and b/packages/alphatab/test-data/visual-tests/issues/no-label-padding-left-with-label.png differ diff --git a/packages/alphatab/test-data/visual-tests/issues/no-label-padding-left-without-label.png b/packages/alphatab/test-data/visual-tests/issues/no-label-padding-left-without-label.png index 973c99c42..0b005f08c 100644 Binary files a/packages/alphatab/test-data/visual-tests/issues/no-label-padding-left-without-label.png and b/packages/alphatab/test-data/visual-tests/issues/no-label-padding-left-without-label.png differ diff --git a/packages/alphatab/test-data/visual-tests/issues/whammy-resize-wrap-380.png b/packages/alphatab/test-data/visual-tests/issues/whammy-resize-wrap-380.png index 720fc6f68..dfb2a6618 100644 Binary files a/packages/alphatab/test-data/visual-tests/issues/whammy-resize-wrap-380.png and b/packages/alphatab/test-data/visual-tests/issues/whammy-resize-wrap-380.png differ diff --git a/packages/alphatab/test-data/visual-tests/issues/whammy-resize-wrap-400.png b/packages/alphatab/test-data/visual-tests/issues/whammy-resize-wrap-400.png index 2b83525dc..d0046a7c1 100644 Binary files a/packages/alphatab/test-data/visual-tests/issues/whammy-resize-wrap-400.png and b/packages/alphatab/test-data/visual-tests/issues/whammy-resize-wrap-400.png differ diff --git a/packages/alphatab/test-data/visual-tests/issues/whammy-resize-wrap-500.png b/packages/alphatab/test-data/visual-tests/issues/whammy-resize-wrap-500.png index 074687891..e35e66117 100644 Binary files a/packages/alphatab/test-data/visual-tests/issues/whammy-resize-wrap-500.png and b/packages/alphatab/test-data/visual-tests/issues/whammy-resize-wrap-500.png differ diff --git a/packages/alphatab/test-data/visual-tests/issues/whammy-resize-wrap-600.png b/packages/alphatab/test-data/visual-tests/issues/whammy-resize-wrap-600.png index ed3765c14..02f37e968 100644 Binary files a/packages/alphatab/test-data/visual-tests/issues/whammy-resize-wrap-600.png and b/packages/alphatab/test-data/visual-tests/issues/whammy-resize-wrap-600.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/accolade-on-revert.png b/packages/alphatab/test-data/visual-tests/layout/accolade-on-revert.png new file mode 100644 index 000000000..895c69bb7 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/layout/accolade-on-revert.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/barnumberdisplay-bar-override-all.png b/packages/alphatab/test-data/visual-tests/layout/barnumberdisplay-bar-override-all.png index 79d27cb06..c3d4b8b6a 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/barnumberdisplay-bar-override-all.png and b/packages/alphatab/test-data/visual-tests/layout/barnumberdisplay-bar-override-all.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/barnumberdisplay-bar-override-first.png b/packages/alphatab/test-data/visual-tests/layout/barnumberdisplay-bar-override-first.png index 85819c041..a5b30e9dd 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/barnumberdisplay-bar-override-first.png and b/packages/alphatab/test-data/visual-tests/layout/barnumberdisplay-bar-override-first.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/barnumberdisplay-bar-override-hide.png b/packages/alphatab/test-data/visual-tests/layout/barnumberdisplay-bar-override-hide.png index 4122aa8c1..a5154473c 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/barnumberdisplay-bar-override-hide.png and b/packages/alphatab/test-data/visual-tests/layout/barnumberdisplay-bar-override-hide.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/barnumberdisplay-stylesheet-all.png b/packages/alphatab/test-data/visual-tests/layout/barnumberdisplay-stylesheet-all.png index 7d764a325..decc4c2de 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/barnumberdisplay-stylesheet-all.png and b/packages/alphatab/test-data/visual-tests/layout/barnumberdisplay-stylesheet-all.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/barnumberdisplay-stylesheet-first.png b/packages/alphatab/test-data/visual-tests/layout/barnumberdisplay-stylesheet-first.png index 3b2cf1554..3e3a61860 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/barnumberdisplay-stylesheet-first.png and b/packages/alphatab/test-data/visual-tests/layout/barnumberdisplay-stylesheet-first.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/barnumberdisplay-stylesheet-hide.png b/packages/alphatab/test-data/visual-tests/layout/barnumberdisplay-stylesheet-hide.png index be4f06aa6..b8d312702 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/barnumberdisplay-stylesheet-hide.png and b/packages/alphatab/test-data/visual-tests/layout/barnumberdisplay-stylesheet-hide.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/brackets-braces-none.png b/packages/alphatab/test-data/visual-tests/layout/brackets-braces-none.png index da6adc54d..b23bdccf6 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/brackets-braces-none.png and b/packages/alphatab/test-data/visual-tests/layout/brackets-braces-none.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/brackets-braces-similar.png b/packages/alphatab/test-data/visual-tests/layout/brackets-braces-similar.png index 11a80853d..7734d91a0 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/brackets-braces-similar.png and b/packages/alphatab/test-data/visual-tests/layout/brackets-braces-similar.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/brackets-braces-staves.png b/packages/alphatab/test-data/visual-tests/layout/brackets-braces-staves.png index 658d5ba81..a03a9b6bb 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/brackets-braces-staves.png and b/packages/alphatab/test-data/visual-tests/layout/brackets-braces-staves.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/ghost-staff-visibility.png b/packages/alphatab/test-data/visual-tests/layout/ghost-staff-visibility.png new file mode 100644 index 000000000..f64953f5e Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/layout/ghost-staff-visibility.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/hide-empty-staves-in-first.png b/packages/alphatab/test-data/visual-tests/layout/hide-empty-staves-in-first.png index ff17ffc0f..6849993c6 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/hide-empty-staves-in-first.png and b/packages/alphatab/test-data/visual-tests/layout/hide-empty-staves-in-first.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/hide-empty-staves.png b/packages/alphatab/test-data/visual-tests/layout/hide-empty-staves.png index 03020a1ed..e1aa87bd2 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/hide-empty-staves.png and b/packages/alphatab/test-data/visual-tests/layout/hide-empty-staves.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/horizontal-layout-5to8.png b/packages/alphatab/test-data/visual-tests/layout/horizontal-layout-5to8.png index 64d6528c2..54a82fa84 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/horizontal-layout-5to8.png and b/packages/alphatab/test-data/visual-tests/layout/horizontal-layout-5to8.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/horizontal-layout.png b/packages/alphatab/test-data/visual-tests/layout/horizontal-layout.png index c0e5e337c..b20fb61e4 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/horizontal-layout.png and b/packages/alphatab/test-data/visual-tests/layout/horizontal-layout.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/multi-system-slur-scale-down-0-1300.png b/packages/alphatab/test-data/visual-tests/layout/multi-system-slur-scale-down-0-1300.png index 0b8712152..442615e71 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/multi-system-slur-scale-down-0-1300.png and b/packages/alphatab/test-data/visual-tests/layout/multi-system-slur-scale-down-0-1300.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/multi-system-slur-scale-down-1-600.png b/packages/alphatab/test-data/visual-tests/layout/multi-system-slur-scale-down-1-600.png index aafe16457..efeed703d 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/multi-system-slur-scale-down-1-600.png and b/packages/alphatab/test-data/visual-tests/layout/multi-system-slur-scale-down-1-600.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/multi-system-slur-scale-down-2-300.png b/packages/alphatab/test-data/visual-tests/layout/multi-system-slur-scale-down-2-300.png index ba8acf74f..dbcacf5be 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/multi-system-slur-scale-down-2-300.png and b/packages/alphatab/test-data/visual-tests/layout/multi-system-slur-scale-down-2-300.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/multi-system-slur-scale-down-3-700.png b/packages/alphatab/test-data/visual-tests/layout/multi-system-slur-scale-down-3-700.png index 169e4ecc7..f923f1fc3 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/multi-system-slur-scale-down-3-700.png and b/packages/alphatab/test-data/visual-tests/layout/multi-system-slur-scale-down-3-700.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/multi-system-slur-scale-up-0-600.png b/packages/alphatab/test-data/visual-tests/layout/multi-system-slur-scale-up-0-600.png index a3f77c935..83460c910 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/multi-system-slur-scale-up-0-600.png and b/packages/alphatab/test-data/visual-tests/layout/multi-system-slur-scale-up-0-600.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/multi-system-slur-scale-up-1-1300.png b/packages/alphatab/test-data/visual-tests/layout/multi-system-slur-scale-up-1-1300.png index b07c2fa24..0ea5df8c2 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/multi-system-slur-scale-up-1-1300.png and b/packages/alphatab/test-data/visual-tests/layout/multi-system-slur-scale-up-1-1300.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/multi-system-slur-scale-up-2-700.png b/packages/alphatab/test-data/visual-tests/layout/multi-system-slur-scale-up-2-700.png index 868480424..fcf1f9a81 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/multi-system-slur-scale-up-2-700.png and b/packages/alphatab/test-data/visual-tests/layout/multi-system-slur-scale-up-2-700.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/multi-system-slur-scale-up-3-300.png b/packages/alphatab/test-data/visual-tests/layout/multi-system-slur-scale-up-3-300.png index ba8acf74f..dbcacf5be 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/multi-system-slur-scale-up-3-300.png and b/packages/alphatab/test-data/visual-tests/layout/multi-system-slur-scale-up-3-300.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/multi-track.png b/packages/alphatab/test-data/visual-tests/layout/multi-track.png index bf211092d..1e1bc383e 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/multi-track.png and b/packages/alphatab/test-data/visual-tests/layout/multi-track.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/multi-voice.png b/packages/alphatab/test-data/visual-tests/layout/multi-voice.png index 2b2720443..015a63d24 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/multi-voice.png and b/packages/alphatab/test-data/visual-tests/layout/multi-voice.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/multibar-rest-all-tracks.png b/packages/alphatab/test-data/visual-tests/layout/multibar-rest-all-tracks.png index 1f43c818a..f5f204d1f 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/multibar-rest-all-tracks.png and b/packages/alphatab/test-data/visual-tests/layout/multibar-rest-all-tracks.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/multibar-rest-multi-track.png b/packages/alphatab/test-data/visual-tests/layout/multibar-rest-multi-track.png index ed5eae90c..56d7218ed 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/multibar-rest-multi-track.png and b/packages/alphatab/test-data/visual-tests/layout/multibar-rest-multi-track.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/multibar-rest-single-track.png b/packages/alphatab/test-data/visual-tests/layout/multibar-rest-single-track.png index e593e2369..b4c890561 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/multibar-rest-single-track.png and b/packages/alphatab/test-data/visual-tests/layout/multibar-rest-single-track.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/page-layout-5barsperrow.png b/packages/alphatab/test-data/visual-tests/layout/page-layout-5barsperrow.png index aea34f916..f35e68464 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/page-layout-5barsperrow.png and b/packages/alphatab/test-data/visual-tests/layout/page-layout-5barsperrow.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/page-layout-5to8.png b/packages/alphatab/test-data/visual-tests/layout/page-layout-5to8.png index 73bd1ca80..1c95be9f4 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/page-layout-5to8.png and b/packages/alphatab/test-data/visual-tests/layout/page-layout-5to8.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/page-layout-justify-last-row.png b/packages/alphatab/test-data/visual-tests/layout/page-layout-justify-last-row.png index 4e6195adf..311b70806 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/page-layout-justify-last-row.png and b/packages/alphatab/test-data/visual-tests/layout/page-layout-justify-last-row.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/page-layout.png b/packages/alphatab/test-data/visual-tests/layout/page-layout.png index b373a000c..fcc3ffd9f 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/page-layout.png and b/packages/alphatab/test-data/visual-tests/layout/page-layout.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/single-staff-brackets-hide.png b/packages/alphatab/test-data/visual-tests/layout/single-staff-brackets-hide.png index 51e8108ac..9395a323a 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/single-staff-brackets-hide.png and b/packages/alphatab/test-data/visual-tests/layout/single-staff-brackets-hide.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/single-staff-brackets-show.png b/packages/alphatab/test-data/visual-tests/layout/single-staff-brackets-show.png index fe6c5715e..f7e8d88df 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/single-staff-brackets-show.png and b/packages/alphatab/test-data/visual-tests/layout/single-staff-brackets-show.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/system-divider.png b/packages/alphatab/test-data/visual-tests/layout/system-divider.png index 9625d3121..030fbda40 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/system-divider.png and b/packages/alphatab/test-data/visual-tests/layout/system-divider.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/system-layout-tex.png b/packages/alphatab/test-data/visual-tests/layout/system-layout-tex.png index caf3d1112..484a2254b 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/system-layout-tex.png and b/packages/alphatab/test-data/visual-tests/layout/system-layout-tex.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/track-names-all-systems-multi.png b/packages/alphatab/test-data/visual-tests/layout/track-names-all-systems-multi.png index 1dca84e65..a4218b4b4 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/track-names-all-systems-multi.png and b/packages/alphatab/test-data/visual-tests/layout/track-names-all-systems-multi.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/track-names-first-system.png b/packages/alphatab/test-data/visual-tests/layout/track-names-first-system.png index 60e9fa97b..664ded577 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/track-names-first-system.png and b/packages/alphatab/test-data/visual-tests/layout/track-names-first-system.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/track-names-full-name-all.png b/packages/alphatab/test-data/visual-tests/layout/track-names-full-name-all.png index ba9ed0614..a2e7abcb0 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/track-names-full-name-all.png and b/packages/alphatab/test-data/visual-tests/layout/track-names-full-name-all.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/track-names-full-name-horizontal.png b/packages/alphatab/test-data/visual-tests/layout/track-names-full-name-horizontal.png index 4c959e648..b227ab5ee 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/track-names-full-name-horizontal.png and b/packages/alphatab/test-data/visual-tests/layout/track-names-full-name-horizontal.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/track-names-full-name-short-name.png b/packages/alphatab/test-data/visual-tests/layout/track-names-full-name-short-name.png index dd736674d..ef4e349ef 100644 Binary files a/packages/alphatab/test-data/visual-tests/layout/track-names-full-name-short-name.png and b/packages/alphatab/test-data/visual-tests/layout/track-names-full-name-short-name.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_16th_Chord-v2_Eighth_Chord-Automatic-Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_16th_Chord-v2_Eighth_Chord-Automatic-Stem.png index bcfcafa47..462704b20 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_16th_Chord-v2_Eighth_Chord-Automatic-Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_16th_Chord-v2_Eighth_Chord-Automatic-Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_16th_Chord-v2_Eighth_Single-Automatic-Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_16th_Chord-v2_Eighth_Single-Automatic-Stem.png index 75a913c05..ba7e82adf 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_16th_Chord-v2_Eighth_Single-Automatic-Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_16th_Chord-v2_Eighth_Single-Automatic-Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_16th_Single-v2_Eighth_Single-Automatic-Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_16th_Single-v2_Eighth_Single-Automatic-Stem.png index effa4ac1e..4200df720 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_16th_Single-v2_Eighth_Single-Automatic-Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_16th_Single-v2_Eighth_Single-Automatic-Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-V2_8th_Flag_Chord-Automatic_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-V2_8th_Flag_Chord-Automatic_Stem.png index fa42aca75..d06434205 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-V2_8th_Flag_Chord-Automatic_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-V2_8th_Flag_Chord-Automatic_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-V2_8th_Flag_Chord-Reversed_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-V2_8th_Flag_Chord-Reversed_Stem.png index d2f101100..27c784e81 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-V2_8th_Flag_Chord-Reversed_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-V2_8th_Flag_Chord-Reversed_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-V2_8th_Flag_Chord-Same_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-V2_8th_Flag_Chord-Same_Stem.png index 3c6c9a4bb..e7c56d8ac 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-V2_8th_Flag_Chord-Same_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-V2_8th_Flag_Chord-Same_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-V2_8th_Flag_Single-Automatic_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-V2_8th_Flag_Single-Automatic_Stem.png index 3253d8bb6..6abf72604 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-V2_8th_Flag_Single-Automatic_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-V2_8th_Flag_Single-Automatic_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-V2_8th_Flag_Single-Reversed_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-V2_8th_Flag_Single-Reversed_Stem.png index 62c633406..de43e89bf 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-V2_8th_Flag_Single-Reversed_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-V2_8th_Flag_Single-Reversed_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-V2_8th_Flag_Single-Same_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-V2_8th_Flag_Single-Same_Stem.png index 316683da2..20ddb74c7 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-V2_8th_Flag_Single-Same_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-V2_8th_Flag_Single-Same_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Half_Chord-Automatic_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Half_Chord-Automatic_Stem.png index 0f00ac13d..53956a4fd 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Half_Chord-Automatic_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Half_Chord-Automatic_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Half_Chord-Reversed_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Half_Chord-Reversed_Stem.png index b27c76911..6f9306fac 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Half_Chord-Reversed_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Half_Chord-Reversed_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Half_Chord-Same_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Half_Chord-Same_Stem.png index 93c35c8f7..6b976e179 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Half_Chord-Same_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Half_Chord-Same_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Half_Single-Automatic_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Half_Single-Automatic_Stem.png index fc01b2656..5d7ca9ded 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Half_Single-Automatic_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Half_Single-Automatic_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Half_Single-Reversed_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Half_Single-Reversed_Stem.png index 2eae959b1..96e3110e1 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Half_Single-Reversed_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Half_Single-Reversed_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Half_Single-Same_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Half_Single-Same_Stem.png index 7fe5fa57e..3dafc4345 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Half_Single-Same_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Half_Single-Same_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Quarter_Chord-Automatic_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Quarter_Chord-Automatic_Stem.png index acdd9406c..922e17284 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Quarter_Chord-Automatic_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Quarter_Chord-Automatic_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Quarter_Chord-Reversed_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Quarter_Chord-Reversed_Stem.png index 6ac3747b2..615c25599 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Quarter_Chord-Reversed_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Quarter_Chord-Reversed_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Quarter_Chord-Same_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Quarter_Chord-Same_Stem.png index d306a10b2..d9685e6ff 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Quarter_Chord-Same_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Quarter_Chord-Same_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Quarter_Single-Automatic_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Quarter_Single-Automatic_Stem.png index 75a86f26a..19f6891f7 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Quarter_Single-Automatic_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Quarter_Single-Automatic_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Quarter_Single-Reversed_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Quarter_Single-Reversed_Stem.png index a3de52243..ac05fa4d9 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Quarter_Single-Reversed_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Quarter_Single-Reversed_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Quarter_Single-Same_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Quarter_Single-Same_Stem.png index 09ce2df6d..08d9c4364 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Quarter_Single-Same_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Chord-v2_Quarter_Single-Same_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Single-v2_8th_Flag_Single-Automatic_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Single-v2_8th_Flag_Single-Automatic_Stem.png index 63c458fdb..135aac249 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Single-v2_8th_Flag_Single-Automatic_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Single-v2_8th_Flag_Single-Automatic_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Single-v2_8th_Flag_Single-Reversed_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Single-v2_8th_Flag_Single-Reversed_Stem.png index f8cc5c46e..e6aeb5950 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Single-v2_8th_Flag_Single-Reversed_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Single-v2_8th_Flag_Single-Reversed_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Single-v2_8th_Flag_Single-Same_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Single-v2_8th_Flag_Single-Same_Stem.png index 581dd2379..8b6a3ab7d 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Single-v2_8th_Flag_Single-Same_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Single-v2_8th_Flag_Single-Same_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Single-v2_Half_Single-Automatic_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Single-v2_Half_Single-Automatic_Stem.png index 7b928e662..c74b39207 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Single-v2_Half_Single-Automatic_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Single-v2_Half_Single-Automatic_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Single-v2_Half_Single-Reversed_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Single-v2_Half_Single-Reversed_Stem.png index 50ad5fce7..a6e09a5f7 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Single-v2_Half_Single-Reversed_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Single-v2_Half_Single-Reversed_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Single-v2_Half_Single-Same_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Single-v2_Half_Single-Same_Stem.png index 8491110ed..c77e65778 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Single-v2_Half_Single-Same_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Single-v2_Half_Single-Same_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Single-v2_Quarter_Single-Automatic_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Single-v2_Quarter_Single-Automatic_Stem.png index 5b29e2771..a933af31c 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Single-v2_Quarter_Single-Automatic_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Single-v2_Quarter_Single-Automatic_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Single-v2_Quarter_Single-Reversed_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Single-v2_Quarter_Single-Reversed_Stem.png index 4b261d4be..d9f6a62c3 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Single-v2_Quarter_Single-Reversed_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Single-v2_Quarter_Single-Reversed_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Single-v2_Quarter_Single-Same_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Single-v2_Quarter_Single-Same_Stem.png index 98701b35f..83c3ea6de 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Single-v2_Quarter_Single-Same_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_8th_Flag_Single-v2_Quarter_Single-Same_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_Eighth_Chord-v2_Eighth_Chord-Automatic-Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_Eighth_Chord-v2_Eighth_Chord-Automatic-Stem.png index b1349f8b8..b8d2f6b2b 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_Eighth_Chord-v2_Eighth_Chord-Automatic-Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_Eighth_Chord-v2_Eighth_Chord-Automatic-Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_Eighth_Chord-v2_Eighth_Single-Automatic-Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_Eighth_Chord-v2_Eighth_Single-Automatic-Stem.png index e498ae674..18f539d1c 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_Eighth_Chord-v2_Eighth_Single-Automatic-Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_Eighth_Chord-v2_Eighth_Single-Automatic-Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_Eighth_Single-v2_Eighth_Single-Automatic-Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_Eighth_Single-v2_Eighth_Single-Automatic-Stem.png index 1abc7b258..e87b2eb5f 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_Eighth_Single-v2_Eighth_Single-Automatic-Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_Eighth_Single-v2_Eighth_Single-Automatic-Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_Full_Chord-v2_Full_Chord-Automatic_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_Full_Chord-v2_Full_Chord-Automatic_Stem.png index 3a6c888ec..5d93dd363 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_Full_Chord-v2_Full_Chord-Automatic_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_Full_Chord-v2_Full_Chord-Automatic_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_Full_Chord-v2_Full_Chord-Reversed_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_Full_Chord-v2_Full_Chord-Reversed_Stem.png index 98912d6ef..c70cd6331 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_Full_Chord-v2_Full_Chord-Reversed_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_Full_Chord-v2_Full_Chord-Reversed_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_Full_Chord-v2_Full_Chord-Same_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_Full_Chord-v2_Full_Chord-Same_Stem.png index 785b5e40d..fd183f4db 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_Full_Chord-v2_Full_Chord-Same_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_Full_Chord-v2_Full_Chord-Same_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_Full_Chord-v2_Full_Single-Automatic_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_Full_Chord-v2_Full_Single-Automatic_Stem.png index c5d7d5b70..d717863b0 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_Full_Chord-v2_Full_Single-Automatic_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_Full_Chord-v2_Full_Single-Automatic_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_Full_Chord-v2_Full_Single-Reversed_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_Full_Chord-v2_Full_Single-Reversed_Stem.png index 1807ea516..4a6344f1c 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_Full_Chord-v2_Full_Single-Reversed_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_Full_Chord-v2_Full_Single-Reversed_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_Full_Chord-v2_Full_Single-Same_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_Full_Chord-v2_Full_Single-Same_Stem.png index d9e2e6a6a..77ff03422 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_Full_Chord-v2_Full_Single-Same_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_Full_Chord-v2_Full_Single-Same_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_Full_Single-v2_Full_Single-Automatic_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_Full_Single-v2_Full_Single-Automatic_Stem.png index 3f578fbee..80a1e890f 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_Full_Single-v2_Full_Single-Automatic_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_Full_Single-v2_Full_Single-Automatic_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_Full_Single-v2_Full_Single-Reversed_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_Full_Single-v2_Full_Single-Reversed_Stem.png index 9901300f4..a78e681bb 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_Full_Single-v2_Full_Single-Reversed_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_Full_Single-v2_Full_Single-Reversed_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_Full_Single-v2_Full_Single-Same_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_Full_Single-v2_Full_Single-Same_Stem.png index a1b8bcaa3..f471293b3 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_Full_Single-v2_Full_Single-Same_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_Full_Single-v2_Full_Single-Same_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_Half_Chord-v2_Full_Chord-Automatic_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_Half_Chord-v2_Full_Chord-Automatic_Stem.png index 4a0dcc810..7d8ae7376 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_Half_Chord-v2_Full_Chord-Automatic_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_Half_Chord-v2_Full_Chord-Automatic_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_Half_Chord-v2_Full_Chord-Reversed_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_Half_Chord-v2_Full_Chord-Reversed_Stem.png index f3e086a08..ddeb760be 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_Half_Chord-v2_Full_Chord-Reversed_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_Half_Chord-v2_Full_Chord-Reversed_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_Half_Chord-v2_Full_Chord-Same_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_Half_Chord-v2_Full_Chord-Same_Stem.png index 807b5daf1..538084340 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_Half_Chord-v2_Full_Chord-Same_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_Half_Chord-v2_Full_Chord-Same_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_Half_Chord-v2_Full_Single-Automatic_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_Half_Chord-v2_Full_Single-Automatic_Stem.png index 673bf2fc1..500ac3a48 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_Half_Chord-v2_Full_Single-Automatic_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_Half_Chord-v2_Full_Single-Automatic_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_Half_Chord-v2_Full_Single-Reversed_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_Half_Chord-v2_Full_Single-Reversed_Stem.png index 67c2d0dfa..9a9de2656 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_Half_Chord-v2_Full_Single-Reversed_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_Half_Chord-v2_Full_Single-Reversed_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_Half_Chord-v2_Full_Single-Same_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_Half_Chord-v2_Full_Single-Same_Stem.png index c1bac4d28..5922ecdce 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_Half_Chord-v2_Full_Single-Same_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_Half_Chord-v2_Full_Single-Same_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_Half_Single-v2_Full_Single-Automatic_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_Half_Single-v2_Full_Single-Automatic_Stem.png index 2bb855fa8..0acc0a843 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_Half_Single-v2_Full_Single-Automatic_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_Half_Single-v2_Full_Single-Automatic_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_Half_Single-v2_Full_Single-Reversed_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_Half_Single-v2_Full_Single-Reversed_Stem.png index 73353d097..358e015aa 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_Half_Single-v2_Full_Single-Reversed_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_Half_Single-v2_Full_Single-Reversed_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_Half_Single-v2_Full_Single-Same_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_Half_Single-v2_Full_Single-Same_Stem.png index a931e4bbf..c64521578 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_Half_Single-v2_Full_Single-Same_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_Half_Single-v2_Full_Single-Same_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-Half_Chord-Automatic_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-Half_Chord-Automatic_Stem.png index 2bf7cb384..d7d77b948 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-Half_Chord-Automatic_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-Half_Chord-Automatic_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-Half_Chord-Reversed_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-Half_Chord-Reversed_Stem.png index c937ab6eb..8f2bc4f70 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-Half_Chord-Reversed_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-Half_Chord-Reversed_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-Half_Chord-Same_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-Half_Chord-Same_Stem.png index c937ab6eb..8f2bc4f70 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-Half_Chord-Same_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-Half_Chord-Same_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-v2_Half_Chord-Automatic_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-v2_Half_Chord-Automatic_Stem.png index 59bff58d9..12eec438b 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-v2_Half_Chord-Automatic_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-v2_Half_Chord-Automatic_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-v2_Half_Chord-Reversed_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-v2_Half_Chord-Reversed_Stem.png index e3ca8435d..1f432de9d 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-v2_Half_Chord-Reversed_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-v2_Half_Chord-Reversed_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-v2_Half_Chord-Same_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-v2_Half_Chord-Same_Stem.png index 5b1f46c6e..ac18c05b2 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-v2_Half_Chord-Same_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-v2_Half_Chord-Same_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-v2_Quarter_Chord-Automatic_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-v2_Quarter_Chord-Automatic_Stem.png index 813da1bc9..f787fd0ee 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-v2_Quarter_Chord-Automatic_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-v2_Quarter_Chord-Automatic_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-v2_Quarter_Chord-Reversed_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-v2_Quarter_Chord-Reversed_Stem.png index 794eb5cad..465487172 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-v2_Quarter_Chord-Reversed_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-v2_Quarter_Chord-Reversed_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-v2_Quarter_Chord-Same_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-v2_Quarter_Chord-Same_Stem.png index d6759670e..75fe6c7a7 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-v2_Quarter_Chord-Same_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-v2_Quarter_Chord-Same_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-v2_Quarter_Single-Automatic_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-v2_Quarter_Single-Automatic_Stem.png index ac11f0068..b8a7afd50 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-v2_Quarter_Single-Automatic_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-v2_Quarter_Single-Automatic_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-v2_Quarter_Single-Reversed_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-v2_Quarter_Single-Reversed_Stem.png index 7545b9561..c5efc92f7 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-v2_Quarter_Single-Reversed_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-v2_Quarter_Single-Reversed_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-v2_Quarter_Single-Same_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-v2_Quarter_Single-Same_Stem.png index 15ff3315f..c12b77be3 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-v2_Quarter_Single-Same_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Chord-v2_Quarter_Single-Same_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Single-v2_Half_Single-Automatic_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Single-v2_Half_Single-Automatic_Stem.png index e0d87a4b8..4c10c87c7 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Single-v2_Half_Single-Automatic_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Single-v2_Half_Single-Automatic_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Single-v2_Half_Single-Reversed_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Single-v2_Half_Single-Reversed_Stem.png index 28f1b3108..a921a11e6 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Single-v2_Half_Single-Reversed_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Single-v2_Half_Single-Reversed_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Single-v2_Half_Single-Same_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Single-v2_Half_Single-Same_Stem.png index 0d8a2bc44..7ca13dca7 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Single-v2_Half_Single-Same_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Single-v2_Half_Single-Same_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Single-v2_Quarter_Single-Automatic_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Single-v2_Quarter_Single-Automatic_Stem.png index 13f85800c..0ca47a0cd 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Single-v2_Quarter_Single-Automatic_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Single-v2_Quarter_Single-Automatic_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Single-v2_Quarter_Single-Reversed_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Single-v2_Quarter_Single-Reversed_Stem.png index 3ea76c3ed..fa1071629 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Single-v2_Quarter_Single-Reversed_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Single-v2_Quarter_Single-Reversed_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Single-v2_Quarter_Single-Same_Stem.png b/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Single-v2_Quarter_Single-Same_Stem.png index 88e42cb89..db683baa6 100644 Binary files a/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Single-v2_Quarter_Single-Same_Stem.png and b/packages/alphatab/test-data/visual-tests/multivoice/v1_Quarter_Single-v2_Quarter_Single-Same_Stem.png differ diff --git a/packages/alphatab/test-data/visual-tests/music-notation/accidentals-advanced-alphatex.png b/packages/alphatab/test-data/visual-tests/music-notation/accidentals-advanced-alphatex.png index 26c7e2060..16822fa83 100644 Binary files a/packages/alphatab/test-data/visual-tests/music-notation/accidentals-advanced-alphatex.png and b/packages/alphatab/test-data/visual-tests/music-notation/accidentals-advanced-alphatex.png differ diff --git a/packages/alphatab/test-data/visual-tests/music-notation/accidentals-advanced.png b/packages/alphatab/test-data/visual-tests/music-notation/accidentals-advanced.png index 86cf350f1..6147ed732 100644 Binary files a/packages/alphatab/test-data/visual-tests/music-notation/accidentals-advanced.png and b/packages/alphatab/test-data/visual-tests/music-notation/accidentals-advanced.png differ diff --git a/packages/alphatab/test-data/visual-tests/music-notation/accidentals.png b/packages/alphatab/test-data/visual-tests/music-notation/accidentals.png index eb037b196..5a1125893 100644 Binary files a/packages/alphatab/test-data/visual-tests/music-notation/accidentals.png and b/packages/alphatab/test-data/visual-tests/music-notation/accidentals.png differ diff --git a/packages/alphatab/test-data/visual-tests/music-notation/beams-advanced.png b/packages/alphatab/test-data/visual-tests/music-notation/beams-advanced.png index d4b2c5a69..fc742240b 100644 Binary files a/packages/alphatab/test-data/visual-tests/music-notation/beams-advanced.png and b/packages/alphatab/test-data/visual-tests/music-notation/beams-advanced.png differ diff --git a/packages/alphatab/test-data/visual-tests/music-notation/brushes-ukulele.png b/packages/alphatab/test-data/visual-tests/music-notation/brushes-ukulele.png index 788944975..461b0acea 100644 Binary files a/packages/alphatab/test-data/visual-tests/music-notation/brushes-ukulele.png and b/packages/alphatab/test-data/visual-tests/music-notation/brushes-ukulele.png differ diff --git a/packages/alphatab/test-data/visual-tests/music-notation/brushes.png b/packages/alphatab/test-data/visual-tests/music-notation/brushes.png index a9b3ff3ae..8709873c6 100644 Binary files a/packages/alphatab/test-data/visual-tests/music-notation/brushes.png and b/packages/alphatab/test-data/visual-tests/music-notation/brushes.png differ diff --git a/packages/alphatab/test-data/visual-tests/music-notation/clefs-gp5.png b/packages/alphatab/test-data/visual-tests/music-notation/clefs-gp5.png index a33ebb29b..cf72f390e 100644 Binary files a/packages/alphatab/test-data/visual-tests/music-notation/clefs-gp5.png and b/packages/alphatab/test-data/visual-tests/music-notation/clefs-gp5.png differ diff --git a/packages/alphatab/test-data/visual-tests/music-notation/clefs.png b/packages/alphatab/test-data/visual-tests/music-notation/clefs.png index b0691407b..1bbc4074d 100644 Binary files a/packages/alphatab/test-data/visual-tests/music-notation/clefs.png and b/packages/alphatab/test-data/visual-tests/music-notation/clefs.png differ diff --git a/packages/alphatab/test-data/visual-tests/music-notation/forced-accidentals.png b/packages/alphatab/test-data/visual-tests/music-notation/forced-accidentals.png index ee577e793..e6831f46d 100644 Binary files a/packages/alphatab/test-data/visual-tests/music-notation/forced-accidentals.png and b/packages/alphatab/test-data/visual-tests/music-notation/forced-accidentals.png differ diff --git a/packages/alphatab/test-data/visual-tests/music-notation/key-signatures-c3.png b/packages/alphatab/test-data/visual-tests/music-notation/key-signatures-c3.png index 78728f37d..3f973f718 100644 Binary files a/packages/alphatab/test-data/visual-tests/music-notation/key-signatures-c3.png and b/packages/alphatab/test-data/visual-tests/music-notation/key-signatures-c3.png differ diff --git a/packages/alphatab/test-data/visual-tests/music-notation/key-signatures-c4.png b/packages/alphatab/test-data/visual-tests/music-notation/key-signatures-c4.png index f90a8ba52..cc8a19356 100644 Binary files a/packages/alphatab/test-data/visual-tests/music-notation/key-signatures-c4.png and b/packages/alphatab/test-data/visual-tests/music-notation/key-signatures-c4.png differ diff --git a/packages/alphatab/test-data/visual-tests/music-notation/key-signatures-f4.png b/packages/alphatab/test-data/visual-tests/music-notation/key-signatures-f4.png index bba7c24eb..23709c1d4 100644 Binary files a/packages/alphatab/test-data/visual-tests/music-notation/key-signatures-f4.png and b/packages/alphatab/test-data/visual-tests/music-notation/key-signatures-f4.png differ diff --git a/packages/alphatab/test-data/visual-tests/music-notation/key-signatures-g2.png b/packages/alphatab/test-data/visual-tests/music-notation/key-signatures-g2.png index e0a4f4335..ff8ee497c 100644 Binary files a/packages/alphatab/test-data/visual-tests/music-notation/key-signatures-g2.png and b/packages/alphatab/test-data/visual-tests/music-notation/key-signatures-g2.png differ diff --git a/packages/alphatab/test-data/visual-tests/music-notation/key-signatures-mixed.png b/packages/alphatab/test-data/visual-tests/music-notation/key-signatures-mixed.png index 7afb9f885..f7a0bb484 100644 Binary files a/packages/alphatab/test-data/visual-tests/music-notation/key-signatures-mixed.png and b/packages/alphatab/test-data/visual-tests/music-notation/key-signatures-mixed.png differ diff --git a/packages/alphatab/test-data/visual-tests/music-notation/key-signatures.png b/packages/alphatab/test-data/visual-tests/music-notation/key-signatures.png index e0a4f4335..ff8ee497c 100644 Binary files a/packages/alphatab/test-data/visual-tests/music-notation/key-signatures.png and b/packages/alphatab/test-data/visual-tests/music-notation/key-signatures.png differ diff --git a/packages/alphatab/test-data/visual-tests/music-notation/notes-rests-beams.png b/packages/alphatab/test-data/visual-tests/music-notation/notes-rests-beams.png index c4cc93483..9ce202d83 100644 Binary files a/packages/alphatab/test-data/visual-tests/music-notation/notes-rests-beams.png and b/packages/alphatab/test-data/visual-tests/music-notation/notes-rests-beams.png differ diff --git a/packages/alphatab/test-data/visual-tests/music-notation/rest-collisions.png b/packages/alphatab/test-data/visual-tests/music-notation/rest-collisions.png index 9e2ecf4ad..168b13ee3 100644 Binary files a/packages/alphatab/test-data/visual-tests/music-notation/rest-collisions.png and b/packages/alphatab/test-data/visual-tests/music-notation/rest-collisions.png differ diff --git a/packages/alphatab/test-data/visual-tests/music-notation/time-signatures.png b/packages/alphatab/test-data/visual-tests/music-notation/time-signatures.png index 14af184af..c77a26cd9 100644 Binary files a/packages/alphatab/test-data/visual-tests/music-notation/time-signatures.png and b/packages/alphatab/test-data/visual-tests/music-notation/time-signatures.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-elements/chord-diagrams-off.png b/packages/alphatab/test-data/visual-tests/notation-elements/chord-diagrams-off.png index b657ec8fd..b972a2834 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-elements/chord-diagrams-off.png and b/packages/alphatab/test-data/visual-tests/notation-elements/chord-diagrams-off.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-elements/chord-diagrams-on.png b/packages/alphatab/test-data/visual-tests/notation-elements/chord-diagrams-on.png index 0eb81e049..ed1eb9dd3 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-elements/chord-diagrams-on.png and b/packages/alphatab/test-data/visual-tests/notation-elements/chord-diagrams-on.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-elements/effects-off.png b/packages/alphatab/test-data/visual-tests/notation-elements/effects-off.png index b1f2056bd..eb9b491b7 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-elements/effects-off.png and b/packages/alphatab/test-data/visual-tests/notation-elements/effects-off.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-elements/effects-on.png b/packages/alphatab/test-data/visual-tests/notation-elements/effects-on.png index 7ea8810da..1d7ccdf07 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-elements/effects-on.png and b/packages/alphatab/test-data/visual-tests/notation-elements/effects-on.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-elements/guitar-tuning-off.png b/packages/alphatab/test-data/visual-tests/notation-elements/guitar-tuning-off.png index b8f0ca5b7..e0e9d4b0c 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-elements/guitar-tuning-off.png and b/packages/alphatab/test-data/visual-tests/notation-elements/guitar-tuning-off.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-elements/guitar-tuning-on.png b/packages/alphatab/test-data/visual-tests/notation-elements/guitar-tuning-on.png index 1f5037778..2080bdf69 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-elements/guitar-tuning-on.png and b/packages/alphatab/test-data/visual-tests/notation-elements/guitar-tuning-on.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-elements/parenthesis-on-tied-bends-off.png b/packages/alphatab/test-data/visual-tests/notation-elements/parenthesis-on-tied-bends-off.png index cc9dea729..2e2aeb878 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-elements/parenthesis-on-tied-bends-off.png and b/packages/alphatab/test-data/visual-tests/notation-elements/parenthesis-on-tied-bends-off.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-elements/parenthesis-on-tied-bends-on.png b/packages/alphatab/test-data/visual-tests/notation-elements/parenthesis-on-tied-bends-on.png index 7a207363e..96a0074c6 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-elements/parenthesis-on-tied-bends-on.png and b/packages/alphatab/test-data/visual-tests/notation-elements/parenthesis-on-tied-bends-on.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-elements/score-info-album.png b/packages/alphatab/test-data/visual-tests/notation-elements/score-info-album.png index 124b47821..0bd1fe19f 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-elements/score-info-album.png and b/packages/alphatab/test-data/visual-tests/notation-elements/score-info-album.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-elements/score-info-all.png b/packages/alphatab/test-data/visual-tests/notation-elements/score-info-all.png index 0913f7076..f4e4512f3 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-elements/score-info-all.png and b/packages/alphatab/test-data/visual-tests/notation-elements/score-info-all.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-elements/score-info-artist.png b/packages/alphatab/test-data/visual-tests/notation-elements/score-info-artist.png index 5d64706b9..4b60ad92a 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-elements/score-info-artist.png and b/packages/alphatab/test-data/visual-tests/notation-elements/score-info-artist.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-elements/score-info-copyright.png b/packages/alphatab/test-data/visual-tests/notation-elements/score-info-copyright.png index ed2811063..e1ca54120 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-elements/score-info-copyright.png and b/packages/alphatab/test-data/visual-tests/notation-elements/score-info-copyright.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-elements/score-info-music.png b/packages/alphatab/test-data/visual-tests/notation-elements/score-info-music.png index 1af0df6be..5b88a197c 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-elements/score-info-music.png and b/packages/alphatab/test-data/visual-tests/notation-elements/score-info-music.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-elements/score-info-subtitle.png b/packages/alphatab/test-data/visual-tests/notation-elements/score-info-subtitle.png index b770d035a..d679b8f4a 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-elements/score-info-subtitle.png and b/packages/alphatab/test-data/visual-tests/notation-elements/score-info-subtitle.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-elements/score-info-title.png b/packages/alphatab/test-data/visual-tests/notation-elements/score-info-title.png index a74705346..7fcf488a8 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-elements/score-info-title.png and b/packages/alphatab/test-data/visual-tests/notation-elements/score-info-title.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-elements/score-info-words-and-music.png b/packages/alphatab/test-data/visual-tests/notation-elements/score-info-words-and-music.png index 36d7afc2f..449700a06 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-elements/score-info-words-and-music.png and b/packages/alphatab/test-data/visual-tests/notation-elements/score-info-words-and-music.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-elements/score-info-words.png b/packages/alphatab/test-data/visual-tests/notation-elements/score-info-words.png index b7afc1c3f..c986eaf4a 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-elements/score-info-words.png and b/packages/alphatab/test-data/visual-tests/notation-elements/score-info-words.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-elements/tab-notes-on-tied-bends-off.png b/packages/alphatab/test-data/visual-tests/notation-elements/tab-notes-on-tied-bends-off.png index 5f8af6dec..86dc55e33 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-elements/tab-notes-on-tied-bends-off.png and b/packages/alphatab/test-data/visual-tests/notation-elements/tab-notes-on-tied-bends-off.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-elements/tab-notes-on-tied-bends-on.png b/packages/alphatab/test-data/visual-tests/notation-elements/tab-notes-on-tied-bends-on.png index fe2ed346b..4c6a91506 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-elements/tab-notes-on-tied-bends-on.png and b/packages/alphatab/test-data/visual-tests/notation-elements/tab-notes-on-tied-bends-on.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-elements/track-names-off.png b/packages/alphatab/test-data/visual-tests/notation-elements/track-names-off.png index ef4d6d984..f88de0fb9 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-elements/track-names-off.png and b/packages/alphatab/test-data/visual-tests/notation-elements/track-names-off.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-elements/track-names-on.png b/packages/alphatab/test-data/visual-tests/notation-elements/track-names-on.png index 53cb474a9..a2b41c950 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-elements/track-names-on.png and b/packages/alphatab/test-data/visual-tests/notation-elements/track-names-on.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-elements/zeros-on-dive-whammys-off.png b/packages/alphatab/test-data/visual-tests/notation-elements/zeros-on-dive-whammys-off.png index 894fbc5ba..e656c913e 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-elements/zeros-on-dive-whammys-off.png and b/packages/alphatab/test-data/visual-tests/notation-elements/zeros-on-dive-whammys-off.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-elements/zeros-on-dive-whammys-on.png b/packages/alphatab/test-data/visual-tests/notation-elements/zeros-on-dive-whammys-on.png index b3e5acf3b..26c802ceb 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-elements/zeros-on-dive-whammys-on.png and b/packages/alphatab/test-data/visual-tests/notation-elements/zeros-on-dive-whammys-on.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/accentuations-default.png b/packages/alphatab/test-data/visual-tests/notation-legend/accentuations-default.png index e2ce3dffb..2eb6a1a78 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/accentuations-default.png and b/packages/alphatab/test-data/visual-tests/notation-legend/accentuations-default.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/bends-default.png b/packages/alphatab/test-data/visual-tests/notation-legend/bends-default.png index 15d1a628d..95bc97e17 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/bends-default.png and b/packages/alphatab/test-data/visual-tests/notation-legend/bends-default.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/bends-songbook.png b/packages/alphatab/test-data/visual-tests/notation-legend/bends-songbook.png index 3b72eb225..cd83d6b8b 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/bends-songbook.png and b/packages/alphatab/test-data/visual-tests/notation-legend/bends-songbook.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/chords-default.png b/packages/alphatab/test-data/visual-tests/notation-legend/chords-default.png index b7c12d398..1be35ec25 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/chords-default.png and b/packages/alphatab/test-data/visual-tests/notation-legend/chords-default.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/chords-songbook.png b/packages/alphatab/test-data/visual-tests/notation-legend/chords-songbook.png index b7c12d398..1be35ec25 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/chords-songbook.png and b/packages/alphatab/test-data/visual-tests/notation-legend/chords-songbook.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/crescendo-default.png b/packages/alphatab/test-data/visual-tests/notation-legend/crescendo-default.png index 15bfb3ae7..fd946eb13 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/crescendo-default.png and b/packages/alphatab/test-data/visual-tests/notation-legend/crescendo-default.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/crescendo-songbook.png b/packages/alphatab/test-data/visual-tests/notation-legend/crescendo-songbook.png index 15bfb3ae7..fd946eb13 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/crescendo-songbook.png and b/packages/alphatab/test-data/visual-tests/notation-legend/crescendo-songbook.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/dead-default.png b/packages/alphatab/test-data/visual-tests/notation-legend/dead-default.png index 8d6e11a02..42859ac30 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/dead-default.png and b/packages/alphatab/test-data/visual-tests/notation-legend/dead-default.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/dead-songbook.png b/packages/alphatab/test-data/visual-tests/notation-legend/dead-songbook.png index 8d6e11a02..42859ac30 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/dead-songbook.png and b/packages/alphatab/test-data/visual-tests/notation-legend/dead-songbook.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/dynamics-default.png b/packages/alphatab/test-data/visual-tests/notation-legend/dynamics-default.png index 4cec11582..8d8974eba 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/dynamics-default.png and b/packages/alphatab/test-data/visual-tests/notation-legend/dynamics-default.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/dynamics-songbook.png b/packages/alphatab/test-data/visual-tests/notation-legend/dynamics-songbook.png index 4cec11582..8d8974eba 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/dynamics-songbook.png and b/packages/alphatab/test-data/visual-tests/notation-legend/dynamics-songbook.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/fingering-default.png b/packages/alphatab/test-data/visual-tests/notation-legend/fingering-default.png index 1fcf16d18..24cd1855b 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/fingering-default.png and b/packages/alphatab/test-data/visual-tests/notation-legend/fingering-default.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/fingering-songbook.png b/packages/alphatab/test-data/visual-tests/notation-legend/fingering-songbook.png index 5795b8636..f46e32184 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/fingering-songbook.png and b/packages/alphatab/test-data/visual-tests/notation-legend/fingering-songbook.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/full-default-large.png b/packages/alphatab/test-data/visual-tests/notation-legend/full-default-large.png index bbf9949be..3b3fb3292 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/full-default-large.png and b/packages/alphatab/test-data/visual-tests/notation-legend/full-default-large.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/full-default-small.png b/packages/alphatab/test-data/visual-tests/notation-legend/full-default-small.png index 57faec369..3b170aaac 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/full-default-small.png and b/packages/alphatab/test-data/visual-tests/notation-legend/full-default-small.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/full-default.png b/packages/alphatab/test-data/visual-tests/notation-legend/full-default.png index 7c49b5266..e901adeb4 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/full-default.png and b/packages/alphatab/test-data/visual-tests/notation-legend/full-default.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/full-songbook.png b/packages/alphatab/test-data/visual-tests/notation-legend/full-songbook.png index 8ccb412b9..a4e7e5184 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/full-songbook.png and b/packages/alphatab/test-data/visual-tests/notation-legend/full-songbook.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/harmonics-default.png b/packages/alphatab/test-data/visual-tests/notation-legend/harmonics-default.png index c129660f8..4a48cc08e 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/harmonics-default.png and b/packages/alphatab/test-data/visual-tests/notation-legend/harmonics-default.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/harmonics-songbook.png b/packages/alphatab/test-data/visual-tests/notation-legend/harmonics-songbook.png index c129660f8..4a48cc08e 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/harmonics-songbook.png and b/packages/alphatab/test-data/visual-tests/notation-legend/harmonics-songbook.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/let-ring-default.png b/packages/alphatab/test-data/visual-tests/notation-legend/let-ring-default.png index d5987aa41..d34783ef8 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/let-ring-default.png and b/packages/alphatab/test-data/visual-tests/notation-legend/let-ring-default.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/let-ring-songbook.png b/packages/alphatab/test-data/visual-tests/notation-legend/let-ring-songbook.png index 942e82a58..42431562f 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/let-ring-songbook.png and b/packages/alphatab/test-data/visual-tests/notation-legend/let-ring-songbook.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/mixed-default.png b/packages/alphatab/test-data/visual-tests/notation-legend/mixed-default.png index 09b1aa47d..483f9d8c9 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/mixed-default.png and b/packages/alphatab/test-data/visual-tests/notation-legend/mixed-default.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/mixed-songbook.png b/packages/alphatab/test-data/visual-tests/notation-legend/mixed-songbook.png index f836570bf..3eab518f7 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/mixed-songbook.png and b/packages/alphatab/test-data/visual-tests/notation-legend/mixed-songbook.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/multi-grace-default.png b/packages/alphatab/test-data/visual-tests/notation-legend/multi-grace-default.png index 48d14d9eb..171ae1f5d 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/multi-grace-default.png and b/packages/alphatab/test-data/visual-tests/notation-legend/multi-grace-default.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/multi-grace-songbook.png b/packages/alphatab/test-data/visual-tests/notation-legend/multi-grace-songbook.png index 60317e205..78aa50f18 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/multi-grace-songbook.png and b/packages/alphatab/test-data/visual-tests/notation-legend/multi-grace-songbook.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/multi-voice-default.png b/packages/alphatab/test-data/visual-tests/notation-legend/multi-voice-default.png index 7516a6217..538c29566 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/multi-voice-default.png and b/packages/alphatab/test-data/visual-tests/notation-legend/multi-voice-default.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/multi-voice-songbook.png b/packages/alphatab/test-data/visual-tests/notation-legend/multi-voice-songbook.png index 7516a6217..538c29566 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/multi-voice-songbook.png and b/packages/alphatab/test-data/visual-tests/notation-legend/multi-voice-songbook.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/ottavia-default.png b/packages/alphatab/test-data/visual-tests/notation-legend/ottavia-default.png index 22ff566de..3ee085ed9 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/ottavia-default.png and b/packages/alphatab/test-data/visual-tests/notation-legend/ottavia-default.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/ottavia-songbook.png b/packages/alphatab/test-data/visual-tests/notation-legend/ottavia-songbook.png index 22ff566de..3ee085ed9 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/ottavia-songbook.png and b/packages/alphatab/test-data/visual-tests/notation-legend/ottavia-songbook.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/pick-stroke-default.png b/packages/alphatab/test-data/visual-tests/notation-legend/pick-stroke-default.png index 22c1fe9cf..3a5bc0138 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/pick-stroke-default.png and b/packages/alphatab/test-data/visual-tests/notation-legend/pick-stroke-default.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/pick-stroke-songbook.png b/packages/alphatab/test-data/visual-tests/notation-legend/pick-stroke-songbook.png index 22c1fe9cf..3a5bc0138 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/pick-stroke-songbook.png and b/packages/alphatab/test-data/visual-tests/notation-legend/pick-stroke-songbook.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-1300.png b/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-1300.png index 5a3b437b2..582f6f827 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-1300.png and b/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-1300.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-1500.png b/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-1500.png index 6114a47d1..dd4f3ba24 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-1500.png and b/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-1500.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-500.png b/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-500.png index d7cc33386..2b4ad82d6 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-500.png and b/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-500.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-800.png b/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-800.png index 7d6c122e6..c79490957 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-800.png and b/packages/alphatab/test-data/visual-tests/notation-legend/resize-sequence-800.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/smufl-petaluma-1300.png b/packages/alphatab/test-data/visual-tests/notation-legend/smufl-petaluma-1300.png index ca949e06a..bf6fba6ee 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/smufl-petaluma-1300.png and b/packages/alphatab/test-data/visual-tests/notation-legend/smufl-petaluma-1300.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/sweep-default.png b/packages/alphatab/test-data/visual-tests/notation-legend/sweep-default.png index f773d0015..4cfede520 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/sweep-default.png and b/packages/alphatab/test-data/visual-tests/notation-legend/sweep-default.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/sweep-songbook.png b/packages/alphatab/test-data/visual-tests/notation-legend/sweep-songbook.png index f773d0015..4cfede520 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/sweep-songbook.png and b/packages/alphatab/test-data/visual-tests/notation-legend/sweep-songbook.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/tap-riff-default.png b/packages/alphatab/test-data/visual-tests/notation-legend/tap-riff-default.png index c1381ef61..1a9b0276e 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/tap-riff-default.png and b/packages/alphatab/test-data/visual-tests/notation-legend/tap-riff-default.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/tap-riff-songbook.png b/packages/alphatab/test-data/visual-tests/notation-legend/tap-riff-songbook.png index c1381ef61..1a9b0276e 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/tap-riff-songbook.png and b/packages/alphatab/test-data/visual-tests/notation-legend/tap-riff-songbook.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/tempo-change-default.png b/packages/alphatab/test-data/visual-tests/notation-legend/tempo-change-default.png index d3b7e09f7..55914ef1a 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/tempo-change-default.png and b/packages/alphatab/test-data/visual-tests/notation-legend/tempo-change-default.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/tempo-change-songbook.png b/packages/alphatab/test-data/visual-tests/notation-legend/tempo-change-songbook.png index 7dc88934c..45fb1c267 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/tempo-change-songbook.png and b/packages/alphatab/test-data/visual-tests/notation-legend/tempo-change-songbook.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/text-default.png b/packages/alphatab/test-data/visual-tests/notation-legend/text-default.png index a7d44f6b3..04d01cab8 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/text-default.png and b/packages/alphatab/test-data/visual-tests/notation-legend/text-default.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/text-songbook.png b/packages/alphatab/test-data/visual-tests/notation-legend/text-songbook.png index a7d44f6b3..04d01cab8 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/text-songbook.png and b/packages/alphatab/test-data/visual-tests/notation-legend/text-songbook.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/tied-note-accidentals-default.png b/packages/alphatab/test-data/visual-tests/notation-legend/tied-note-accidentals-default.png index 8797e3235..cc0e32c91 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/tied-note-accidentals-default.png and b/packages/alphatab/test-data/visual-tests/notation-legend/tied-note-accidentals-default.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/tied-note-accidentals-songbook.png b/packages/alphatab/test-data/visual-tests/notation-legend/tied-note-accidentals-songbook.png index b646a0e7f..fcdc753fc 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/tied-note-accidentals-songbook.png and b/packages/alphatab/test-data/visual-tests/notation-legend/tied-note-accidentals-songbook.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/trill-default.png b/packages/alphatab/test-data/visual-tests/notation-legend/trill-default.png index 4e59d6b19..a9b252714 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/trill-default.png and b/packages/alphatab/test-data/visual-tests/notation-legend/trill-default.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/trill-songbook.png b/packages/alphatab/test-data/visual-tests/notation-legend/trill-songbook.png index 4e59d6b19..a9b252714 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/trill-songbook.png and b/packages/alphatab/test-data/visual-tests/notation-legend/trill-songbook.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/triplet-feel-default.png b/packages/alphatab/test-data/visual-tests/notation-legend/triplet-feel-default.png index 0ca246444..c20ca7a2b 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/triplet-feel-default.png and b/packages/alphatab/test-data/visual-tests/notation-legend/triplet-feel-default.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/triplet-feel-songbook.png b/packages/alphatab/test-data/visual-tests/notation-legend/triplet-feel-songbook.png index 0ca246444..c20ca7a2b 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/triplet-feel-songbook.png and b/packages/alphatab/test-data/visual-tests/notation-legend/triplet-feel-songbook.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/vibrato-default.png b/packages/alphatab/test-data/visual-tests/notation-legend/vibrato-default.png index 32c6f8b0a..bb6512154 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/vibrato-default.png and b/packages/alphatab/test-data/visual-tests/notation-legend/vibrato-default.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/vibrato-songbook.png b/packages/alphatab/test-data/visual-tests/notation-legend/vibrato-songbook.png index 32c6f8b0a..bb6512154 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/vibrato-songbook.png and b/packages/alphatab/test-data/visual-tests/notation-legend/vibrato-songbook.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/wah-default.png b/packages/alphatab/test-data/visual-tests/notation-legend/wah-default.png index a4df43be3..427b094fa 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/wah-default.png and b/packages/alphatab/test-data/visual-tests/notation-legend/wah-default.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/wah-songbook.png b/packages/alphatab/test-data/visual-tests/notation-legend/wah-songbook.png index a4df43be3..427b094fa 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/wah-songbook.png and b/packages/alphatab/test-data/visual-tests/notation-legend/wah-songbook.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/whammy-default.png b/packages/alphatab/test-data/visual-tests/notation-legend/whammy-default.png index 453fd7b73..491715834 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/whammy-default.png and b/packages/alphatab/test-data/visual-tests/notation-legend/whammy-default.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-legend/whammy-songbook.png b/packages/alphatab/test-data/visual-tests/notation-legend/whammy-songbook.png index 18078017a..e5d15379d 100644 Binary files a/packages/alphatab/test-data/visual-tests/notation-legend/whammy-songbook.png and b/packages/alphatab/test-data/visual-tests/notation-legend/whammy-songbook.png differ diff --git a/packages/alphatab/test-data/visual-tests/prefix-overhead/page-automatic-ignores-displayscale.png b/packages/alphatab/test-data/visual-tests/prefix-overhead/page-automatic-ignores-displayscale.png index 629cfe4e2..9fff02511 100644 Binary files a/packages/alphatab/test-data/visual-tests/prefix-overhead/page-automatic-ignores-displayscale.png and b/packages/alphatab/test-data/visual-tests/prefix-overhead/page-automatic-ignores-displayscale.png differ diff --git a/packages/alphatab/test-data/visual-tests/prefix-overhead/page-automatic-prefix-system-start.png b/packages/alphatab/test-data/visual-tests/prefix-overhead/page-automatic-prefix-system-start.png index c5d9d4796..740443b15 100644 Binary files a/packages/alphatab/test-data/visual-tests/prefix-overhead/page-automatic-prefix-system-start.png and b/packages/alphatab/test-data/visual-tests/prefix-overhead/page-automatic-prefix-system-start.png differ diff --git a/packages/alphatab/test-data/visual-tests/prefix-overhead/parchment-authored-scale-with-prefix.png b/packages/alphatab/test-data/visual-tests/prefix-overhead/parchment-authored-scale-with-prefix.png index 4a3abc570..3e8198917 100644 Binary files a/packages/alphatab/test-data/visual-tests/prefix-overhead/parchment-authored-scale-with-prefix.png and b/packages/alphatab/test-data/visual-tests/prefix-overhead/parchment-authored-scale-with-prefix.png differ diff --git a/packages/alphatab/test-data/visual-tests/prefix-overhead/parchment-prefix-midline-keysig-change.png b/packages/alphatab/test-data/visual-tests/prefix-overhead/parchment-prefix-midline-keysig-change.png index 3c15332d7..926f0f1e3 100644 Binary files a/packages/alphatab/test-data/visual-tests/prefix-overhead/parchment-prefix-midline-keysig-change.png and b/packages/alphatab/test-data/visual-tests/prefix-overhead/parchment-prefix-midline-keysig-change.png differ diff --git a/packages/alphatab/test-data/visual-tests/prefix-overhead/parchment-prefix-system-start.png b/packages/alphatab/test-data/visual-tests/prefix-overhead/parchment-prefix-system-start.png index 787440099..5f5724a71 100644 Binary files a/packages/alphatab/test-data/visual-tests/prefix-overhead/parchment-prefix-system-start.png and b/packages/alphatab/test-data/visual-tests/prefix-overhead/parchment-prefix-system-start.png differ diff --git a/packages/alphatab/test-data/visual-tests/skyline-coverage/beat-effects-above-and-below.png b/packages/alphatab/test-data/visual-tests/skyline-coverage/beat-effects-above-and-below.png new file mode 100644 index 000000000..7b27ec0bf Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/skyline-coverage/beat-effects-above-and-below.png differ diff --git a/packages/alphatab/test-data/visual-tests/skyline-coverage/high-and-low-content.png b/packages/alphatab/test-data/visual-tests/skyline-coverage/high-and-low-content.png new file mode 100644 index 000000000..70a07d736 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/skyline-coverage/high-and-low-content.png differ diff --git a/packages/alphatab/test-data/visual-tests/skyline-coverage/mixed-staves.png b/packages/alphatab/test-data/visual-tests/skyline-coverage/mixed-staves.png new file mode 100644 index 000000000..9662c76ba Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/skyline-coverage/mixed-staves.png differ diff --git a/packages/alphatab/test-data/visual-tests/skyline-coverage/multi-voice.png b/packages/alphatab/test-data/visual-tests/skyline-coverage/multi-voice.png new file mode 100644 index 000000000..4e9b5b43b Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/skyline-coverage/multi-voice.png differ diff --git a/packages/alphatab/test-data/visual-tests/skyline-coverage/numbered-notation.png b/packages/alphatab/test-data/visual-tests/skyline-coverage/numbered-notation.png new file mode 100644 index 000000000..8cde2f68d Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/skyline-coverage/numbered-notation.png differ diff --git a/packages/alphatab/test-data/visual-tests/skyline-coverage/slash-rhythm.png b/packages/alphatab/test-data/visual-tests/skyline-coverage/slash-rhythm.png new file mode 100644 index 000000000..af8c0898d Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/skyline-coverage/slash-rhythm.png differ diff --git a/packages/alphatab/test-data/visual-tests/skyline-resize-coverage/beamed-eighths-w1300.png b/packages/alphatab/test-data/visual-tests/skyline-resize-coverage/beamed-eighths-w1300.png new file mode 100644 index 000000000..55b6a886b Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/skyline-resize-coverage/beamed-eighths-w1300.png differ diff --git a/packages/alphatab/test-data/visual-tests/skyline-resize-coverage/beamed-eighths-w700.png b/packages/alphatab/test-data/visual-tests/skyline-resize-coverage/beamed-eighths-w700.png new file mode 100644 index 000000000..cf8aabe03 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/skyline-resize-coverage/beamed-eighths-w700.png differ diff --git a/packages/alphatab/test-data/visual-tests/skyline-resize-coverage/mixed-content-w1300.png b/packages/alphatab/test-data/visual-tests/skyline-resize-coverage/mixed-content-w1300.png new file mode 100644 index 000000000..1cf9aa2c3 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/skyline-resize-coverage/mixed-content-w1300.png differ diff --git a/packages/alphatab/test-data/visual-tests/skyline-resize-coverage/mixed-content-w500.png b/packages/alphatab/test-data/visual-tests/skyline-resize-coverage/mixed-content-w500.png new file mode 100644 index 000000000..bbc607db9 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/skyline-resize-coverage/mixed-content-w500.png differ diff --git a/packages/alphatab/test-data/visual-tests/skyline-resize-coverage/mixed-content-w800.png b/packages/alphatab/test-data/visual-tests/skyline-resize-coverage/mixed-content-w800.png new file mode 100644 index 000000000..8962044b5 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/skyline-resize-coverage/mixed-content-w800.png differ diff --git a/packages/alphatab/test-data/visual-tests/skyline-resize-coverage/multi-voice-w1300.png b/packages/alphatab/test-data/visual-tests/skyline-resize-coverage/multi-voice-w1300.png new file mode 100644 index 000000000..526e50378 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/skyline-resize-coverage/multi-voice-w1300.png differ diff --git a/packages/alphatab/test-data/visual-tests/skyline-resize-coverage/multi-voice-w500.png b/packages/alphatab/test-data/visual-tests/skyline-resize-coverage/multi-voice-w500.png new file mode 100644 index 000000000..69498cc27 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/skyline-resize-coverage/multi-voice-w500.png differ diff --git a/packages/alphatab/test-data/visual-tests/skyline-resize-coverage/numbered-notation-w1300.png b/packages/alphatab/test-data/visual-tests/skyline-resize-coverage/numbered-notation-w1300.png new file mode 100644 index 000000000..ab8e310f3 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/skyline-resize-coverage/numbered-notation-w1300.png differ diff --git a/packages/alphatab/test-data/visual-tests/skyline-resize-coverage/numbered-notation-w700.png b/packages/alphatab/test-data/visual-tests/skyline-resize-coverage/numbered-notation-w700.png new file mode 100644 index 000000000..746b7b35e Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/skyline-resize-coverage/numbered-notation-w700.png differ diff --git a/packages/alphatab/test-data/visual-tests/skyline-resize-coverage/slash-rhythm-w1300.png b/packages/alphatab/test-data/visual-tests/skyline-resize-coverage/slash-rhythm-w1300.png new file mode 100644 index 000000000..0b8a8960e Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/skyline-resize-coverage/slash-rhythm-w1300.png differ diff --git a/packages/alphatab/test-data/visual-tests/skyline-resize-coverage/slash-rhythm-w700.png b/packages/alphatab/test-data/visual-tests/skyline-resize-coverage/slash-rhythm-w700.png new file mode 100644 index 000000000..87efaadaa Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/skyline-resize-coverage/slash-rhythm-w700.png differ diff --git a/packages/alphatab/test-data/visual-tests/skyline-resize-coverage/tuplet-w1300.png b/packages/alphatab/test-data/visual-tests/skyline-resize-coverage/tuplet-w1300.png new file mode 100644 index 000000000..be4beb67c Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/skyline-resize-coverage/tuplet-w1300.png differ diff --git a/packages/alphatab/test-data/visual-tests/skyline-resize-coverage/tuplet-w700.png b/packages/alphatab/test-data/visual-tests/skyline-resize-coverage/tuplet-w700.png new file mode 100644 index 000000000..b96648ba7 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/skyline-resize-coverage/tuplet-w700.png differ diff --git a/packages/alphatab/test-data/visual-tests/special-notes/beaming-mode.png b/packages/alphatab/test-data/visual-tests/special-notes/beaming-mode.png index 6832b9f23..19eecf065 100644 Binary files a/packages/alphatab/test-data/visual-tests/special-notes/beaming-mode.png and b/packages/alphatab/test-data/visual-tests/special-notes/beaming-mode.png differ diff --git a/packages/alphatab/test-data/visual-tests/special-notes/dead-notes.png b/packages/alphatab/test-data/visual-tests/special-notes/dead-notes.png index 3b2f3221a..5f133c857 100644 Binary files a/packages/alphatab/test-data/visual-tests/special-notes/dead-notes.png and b/packages/alphatab/test-data/visual-tests/special-notes/dead-notes.png differ diff --git a/packages/alphatab/test-data/visual-tests/special-notes/ghost-notes.png b/packages/alphatab/test-data/visual-tests/special-notes/ghost-notes.png index 7a8b39669..10e3f551b 100644 Binary files a/packages/alphatab/test-data/visual-tests/special-notes/ghost-notes.png and b/packages/alphatab/test-data/visual-tests/special-notes/ghost-notes.png differ diff --git a/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-advanced-1300-2.png b/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-advanced-1300-2.png index fa4355ab3..94501ebd9 100644 Binary files a/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-advanced-1300-2.png and b/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-advanced-1300-2.png differ diff --git a/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-advanced-1300.png b/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-advanced-1300.png index 4699573ff..94501ebd9 100644 Binary files a/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-advanced-1300.png and b/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-advanced-1300.png differ diff --git a/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-advanced-800.png b/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-advanced-800.png index 19d4399ca..42585a232 100644 Binary files a/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-advanced-800.png and b/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-advanced-800.png differ diff --git a/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-advanced.png b/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-advanced.png index 4699573ff..94501ebd9 100644 Binary files a/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-advanced.png and b/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-advanced.png differ diff --git a/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-alignment.png b/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-alignment.png index 0a07b147e..2f0025676 100644 Binary files a/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-alignment.png and b/packages/alphatab/test-data/visual-tests/special-notes/grace-notes-alignment.png differ diff --git a/packages/alphatab/test-data/visual-tests/special-notes/grace-notes.png b/packages/alphatab/test-data/visual-tests/special-notes/grace-notes.png index 294d43660..ea0f22f47 100644 Binary files a/packages/alphatab/test-data/visual-tests/special-notes/grace-notes.png and b/packages/alphatab/test-data/visual-tests/special-notes/grace-notes.png differ diff --git a/packages/alphatab/test-data/visual-tests/special-notes/tied-notes.png b/packages/alphatab/test-data/visual-tests/special-notes/tied-notes.png index ad7d85254..8893423ae 100644 Binary files a/packages/alphatab/test-data/visual-tests/special-notes/tied-notes.png and b/packages/alphatab/test-data/visual-tests/special-notes/tied-notes.png differ diff --git a/packages/alphatab/test-data/visual-tests/special-tracks/drum-tabs.png b/packages/alphatab/test-data/visual-tests/special-tracks/drum-tabs.png index 7676a2f96..466cbb670 100644 Binary files a/packages/alphatab/test-data/visual-tests/special-tracks/drum-tabs.png and b/packages/alphatab/test-data/visual-tests/special-tracks/drum-tabs.png differ diff --git a/packages/alphatab/test-data/visual-tests/special-tracks/grand-staff.png b/packages/alphatab/test-data/visual-tests/special-tracks/grand-staff.png index f202c8b3c..4c7a0c5f8 100644 Binary files a/packages/alphatab/test-data/visual-tests/special-tracks/grand-staff.png and b/packages/alphatab/test-data/visual-tests/special-tracks/grand-staff.png differ diff --git a/packages/alphatab/test-data/visual-tests/special-tracks/numbered-durations.png b/packages/alphatab/test-data/visual-tests/special-tracks/numbered-durations.png index 9b535c0e7..93ef25886 100644 Binary files a/packages/alphatab/test-data/visual-tests/special-tracks/numbered-durations.png and b/packages/alphatab/test-data/visual-tests/special-tracks/numbered-durations.png differ diff --git a/packages/alphatab/test-data/visual-tests/special-tracks/numbered-tuplets.png b/packages/alphatab/test-data/visual-tests/special-tracks/numbered-tuplets.png index 056c970d9..2ee015bca 100644 Binary files a/packages/alphatab/test-data/visual-tests/special-tracks/numbered-tuplets.png and b/packages/alphatab/test-data/visual-tests/special-tracks/numbered-tuplets.png differ diff --git a/packages/alphatab/test-data/visual-tests/special-tracks/numbered.png b/packages/alphatab/test-data/visual-tests/special-tracks/numbered.png index 554fbd63e..631aee2ef 100644 Binary files a/packages/alphatab/test-data/visual-tests/special-tracks/numbered.png and b/packages/alphatab/test-data/visual-tests/special-tracks/numbered.png differ diff --git a/packages/alphatab/test-data/visual-tests/special-tracks/percussion.png b/packages/alphatab/test-data/visual-tests/special-tracks/percussion.png index 443bf9f6b..41c57fb74 100644 Binary files a/packages/alphatab/test-data/visual-tests/special-tracks/percussion.png and b/packages/alphatab/test-data/visual-tests/special-tracks/percussion.png differ diff --git a/packages/alphatab/test-data/visual-tests/special-tracks/slash.png b/packages/alphatab/test-data/visual-tests/special-tracks/slash.png index 7220afa89..88a46d56d 100644 Binary files a/packages/alphatab/test-data/visual-tests/special-tracks/slash.png and b/packages/alphatab/test-data/visual-tests/special-tracks/slash.png differ diff --git a/packages/alphatab/test-data/visual-tests/system-spacing/shared-min-duration-aligns-same-duration-notes.png b/packages/alphatab/test-data/visual-tests/system-spacing/shared-min-duration-aligns-same-duration-notes.png index b853426b0..523b9dff7 100644 Binary files a/packages/alphatab/test-data/visual-tests/system-spacing/shared-min-duration-aligns-same-duration-notes.png and b/packages/alphatab/test-data/visual-tests/system-spacing/shared-min-duration-aligns-same-duration-notes.png differ diff --git a/packages/alphatab/test-data/visual-tests/system-spacing/shared-min-duration-horizontal-preserves-local.png b/packages/alphatab/test-data/visual-tests/system-spacing/shared-min-duration-horizontal-preserves-local.png index e781070d0..a06991718 100644 Binary files a/packages/alphatab/test-data/visual-tests/system-spacing/shared-min-duration-horizontal-preserves-local.png and b/packages/alphatab/test-data/visual-tests/system-spacing/shared-min-duration-horizontal-preserves-local.png differ diff --git a/packages/alphatab/test-data/visual-tests/system-spacing/shared-min-duration-multiple-short-arrivals.png b/packages/alphatab/test-data/visual-tests/system-spacing/shared-min-duration-multiple-short-arrivals.png index e61c4f461..e2f4515a3 100644 Binary files a/packages/alphatab/test-data/visual-tests/system-spacing/shared-min-duration-multiple-short-arrivals.png and b/packages/alphatab/test-data/visual-tests/system-spacing/shared-min-duration-multiple-short-arrivals.png differ diff --git a/packages/alphatab/test-data/visual-tests/system-spacing/shared-min-duration-page-automatic.png b/packages/alphatab/test-data/visual-tests/system-spacing/shared-min-duration-page-automatic.png index 3159ca11a..aa00e906f 100644 Binary files a/packages/alphatab/test-data/visual-tests/system-spacing/shared-min-duration-page-automatic.png and b/packages/alphatab/test-data/visual-tests/system-spacing/shared-min-duration-page-automatic.png differ diff --git a/packages/alphatab/test-data/visual-tests/system-spacing/shared-min-duration-per-system-isolation.png b/packages/alphatab/test-data/visual-tests/system-spacing/shared-min-duration-per-system-isolation.png index 92dbb7cf3..65b289b38 100644 Binary files a/packages/alphatab/test-data/visual-tests/system-spacing/shared-min-duration-per-system-isolation.png and b/packages/alphatab/test-data/visual-tests/system-spacing/shared-min-duration-per-system-isolation.png differ diff --git a/packages/alphatab/test-data/visual-tests/system-spacing/shared-min-duration-reconciles-on-resize.png b/packages/alphatab/test-data/visual-tests/system-spacing/shared-min-duration-reconciles-on-resize.png index 496b23b7b..5d5af41ff 100644 Binary files a/packages/alphatab/test-data/visual-tests/system-spacing/shared-min-duration-reconciles-on-resize.png and b/packages/alphatab/test-data/visual-tests/system-spacing/shared-min-duration-reconciles-on-resize.png differ diff --git a/packages/alphatab/test-data/visual-tests/system-spacing/shared-min-duration-shorter-note-first.png b/packages/alphatab/test-data/visual-tests/system-spacing/shared-min-duration-shorter-note-first.png index 7fc5a3594..0cab9ac02 100644 Binary files a/packages/alphatab/test-data/visual-tests/system-spacing/shared-min-duration-shorter-note-first.png and b/packages/alphatab/test-data/visual-tests/system-spacing/shared-min-duration-shorter-note-first.png differ diff --git a/packages/alphatab/test-data/visual-tests/system-spacing/stretch-formula-duration-spacing.png b/packages/alphatab/test-data/visual-tests/system-spacing/stretch-formula-duration-spacing.png index 109e71804..e368df7d5 100644 Binary files a/packages/alphatab/test-data/visual-tests/system-spacing/stretch-formula-duration-spacing.png and b/packages/alphatab/test-data/visual-tests/system-spacing/stretch-formula-duration-spacing.png differ diff --git a/packages/alphatab/test-data/visual-tests/systems-layout/bars-adjusted-automatic.png b/packages/alphatab/test-data/visual-tests/systems-layout/bars-adjusted-automatic.png index b82cea073..750c7fb4b 100644 Binary files a/packages/alphatab/test-data/visual-tests/systems-layout/bars-adjusted-automatic.png and b/packages/alphatab/test-data/visual-tests/systems-layout/bars-adjusted-automatic.png differ diff --git a/packages/alphatab/test-data/visual-tests/systems-layout/bars-adjusted-model.png b/packages/alphatab/test-data/visual-tests/systems-layout/bars-adjusted-model.png index bdc5ca000..65b1cdec0 100644 Binary files a/packages/alphatab/test-data/visual-tests/systems-layout/bars-adjusted-model.png and b/packages/alphatab/test-data/visual-tests/systems-layout/bars-adjusted-model.png differ diff --git a/packages/alphatab/test-data/visual-tests/systems-layout/horizontal-fixed-sizes-single-track.png b/packages/alphatab/test-data/visual-tests/systems-layout/horizontal-fixed-sizes-single-track.png index d5eae200e..211f53ce2 100644 Binary files a/packages/alphatab/test-data/visual-tests/systems-layout/horizontal-fixed-sizes-single-track.png and b/packages/alphatab/test-data/visual-tests/systems-layout/horizontal-fixed-sizes-single-track.png differ diff --git a/packages/alphatab/test-data/visual-tests/systems-layout/horizontal-fixed-sizes-two-tracks.png b/packages/alphatab/test-data/visual-tests/systems-layout/horizontal-fixed-sizes-two-tracks.png index e631c084e..4b70d8022 100644 Binary files a/packages/alphatab/test-data/visual-tests/systems-layout/horizontal-fixed-sizes-two-tracks.png and b/packages/alphatab/test-data/visual-tests/systems-layout/horizontal-fixed-sizes-two-tracks.png differ diff --git a/packages/alphatab/test-data/visual-tests/systems-layout/multi-track-single-track.png b/packages/alphatab/test-data/visual-tests/systems-layout/multi-track-single-track.png index b2a837a48..513352857 100644 Binary files a/packages/alphatab/test-data/visual-tests/systems-layout/multi-track-single-track.png and b/packages/alphatab/test-data/visual-tests/systems-layout/multi-track-single-track.png differ diff --git a/packages/alphatab/test-data/visual-tests/systems-layout/multi-track-two-tracks.png b/packages/alphatab/test-data/visual-tests/systems-layout/multi-track-two-tracks.png index 7f96dc6e6..7187c9542 100644 Binary files a/packages/alphatab/test-data/visual-tests/systems-layout/multi-track-two-tracks.png and b/packages/alphatab/test-data/visual-tests/systems-layout/multi-track-two-tracks.png differ diff --git a/packages/alphatab/test-data/visual-tests/systems-layout/resized.png b/packages/alphatab/test-data/visual-tests/systems-layout/resized.png index fc29e5df3..ade4d2eb3 100644 Binary files a/packages/alphatab/test-data/visual-tests/systems-layout/resized.png and b/packages/alphatab/test-data/visual-tests/systems-layout/resized.png differ diff --git a/packages/alphatab/test/rendering/AccoladeIdempotence.test.ts b/packages/alphatab/test/rendering/AccoladeIdempotence.test.ts new file mode 100644 index 000000000..2eff3c714 --- /dev/null +++ b/packages/alphatab/test/rendering/AccoladeIdempotence.test.ts @@ -0,0 +1,145 @@ +/** + * Asserts the accolade contribution to system.width is applied exactly once + * per cycle. + */ +import { AlphaTabApiBase } from '@coderline/alphatab/AlphaTabApiBase'; +import { AlphaTabError, AlphaTabErrorType } from '@coderline/alphatab/AlphaTabError'; +import { AlphaTexImporter } from '@coderline/alphatab/importer/AlphaTexImporter'; +import { ByteBuffer } from '@coderline/alphatab/io/ByteBuffer'; +import { JsonConverter } from '@coderline/alphatab/model/JsonConverter'; +import type { Score } from '@coderline/alphatab/model/Score'; +import type { ScoreRenderer } from '@coderline/alphatab/rendering/ScoreRenderer'; +import type { ScoreRendererWrapper } from '@coderline/alphatab/rendering/ScoreRendererWrapper'; +import type { StaffSystem } from '@coderline/alphatab/rendering/staves/StaffSystem'; +import { Settings } from '@coderline/alphatab/Settings'; +import { describe, expect, it } from 'vitest'; +import { TestUiFacade } from '../visualTests/TestUiFacade'; +import { VisualTestHelper } from '../visualTests/VisualTestHelper'; + +/** + * @record + * @internal + */ +interface SystemMetrics { + accoladeWidth: number; + width: number; + computedWidth: number; + barWidths: number[]; +} + +/** + * @record + * @internal + */ +interface ScoreLayoutInternals { + systems: StaffSystem[]; +} + +/** + * @internal + */ +class AccoladeIdempotenceHelper { + public static async loadScore(tex: string): Promise { + const settings = new Settings(); + const importer = new AlphaTexImporter(); + importer.init(ByteBuffer.fromString(tex), settings); + return importer.readScore(); + } + + public static async renderAndCaptureSystem0(tex: string, width: number): Promise { + await VisualTestHelper.prepareAlphaSkia(); + const score = await AccoladeIdempotenceHelper.loadScore(tex); + const settings = new Settings(); + VisualTestHelper.prepareSettingsForTest(settings); + + const uiFacade = new TestUiFacade(); + uiFacade.rootContainer.width = width; + const api = new AlphaTabApiBase(uiFacade, settings); + + let captured: SystemMetrics | null = null; + try { + await new Promise((resolve, reject) => { + api.renderer.postRenderFinished.on(() => { + const wrapper = api.renderer as unknown as ScoreRendererWrapper; + const inner = wrapper.instance as unknown as ScoreRenderer; + const systems = (inner.layout as unknown as ScoreLayoutInternals).systems; + if (systems.length === 0) { + reject(new Error('expected at least one system')); + return; + } + const s = systems[0]; + const firstStaffGroup = s.staves[0]; + const firstStaff = firstStaffGroup.staves[0]; + const metrics: SystemMetrics = { + accoladeWidth: s.accoladeWidth, + width: s.width, + computedWidth: s.computedWidth, + barWidths: firstStaff.barRenderers.map(r => r.width) + }; + captured = metrics; + resolve(); + }); + api.error.on(e => { + reject( + new AlphaTabError( + AlphaTabErrorType.General, + `Failed to render score (${e.message} ${e.stack})`, + e + ) + ); + }); + const renderScore = JsonConverter.jsObjectToScore(JsonConverter.scoreToJsObject(score), settings); + api.renderScore(renderScore, [0]); + setTimeout(() => reject(new Error('render timed out')), 5000); + }); + } finally { + api.destroy(); + } + return captured!; + } + + public static sum(values: number[]): number { + let total = 0; + for (const v of values) { + total += v; + } + return total; + } +} + +describe('AccoladeIdempotence', () => { + it('accolade contribution applied exactly once for a multi-bar single-system score', async () => { + const tex = ` + \\track "T1" + \\staff {score} + C4.4 *4 | C4.4 *4 | C4.4 *4 | C4.4 *4 | C4.4 *4 + `; + + const metrics = await AccoladeIdempotenceHelper.renderAndCaptureSystem0(tex, 3000); + + expect(metrics.barWidths.length).toBe(5); + expect(metrics.accoladeWidth).toBeGreaterThanOrEqual(0); + + const sumBarWidths = AccoladeIdempotenceHelper.sum(metrics.barWidths); + const accoladePortion = metrics.width - sumBarWidths; + expect(accoladePortion).toBeCloseTo(metrics.accoladeWidth, 1); + + const computedAccoladePortion = metrics.computedWidth - sumBarWidths; + expect(computedAccoladePortion).toBeCloseTo(metrics.accoladeWidth, 1); + }); + + it('accolade contribution stable across renders with same inputs', async () => { + const tex = ` + \\track "T1" + \\staff {score} + C4.4 *4 | C4.4 *4 | C4.4 *4 + `; + + const first = await AccoladeIdempotenceHelper.renderAndCaptureSystem0(tex, 3000); + const second = await AccoladeIdempotenceHelper.renderAndCaptureSystem0(tex, 3000); + + expect(second.accoladeWidth).toBe(first.accoladeWidth); + expect(second.width).toBe(first.width); + expect(second.computedWidth).toBe(first.computedWidth); + }); +}); diff --git a/packages/alphatab/test/rendering/PendingBeatEffects.test.ts b/packages/alphatab/test/rendering/PendingBeatEffects.test.ts new file mode 100644 index 000000000..896c27ffb --- /dev/null +++ b/packages/alphatab/test/rendering/PendingBeatEffects.test.ts @@ -0,0 +1,60 @@ +/** + * Per-beat effect overflow ranges live on each BeatContainerGlyphBase as + * `pendingEffectOverflows`. + */ +import type { BeatEffectOverflow } from '@coderline/alphatab/rendering/glyphs/BeatContainerGlyph'; +import { MultiBarRestBeatContainerGlyph } from '@coderline/alphatab/rendering/MultiBarRestBeatContainerGlyph'; +import { describe, expect, it } from 'vitest'; + +describe('PendingBeatEffects', () => { + it('MultiBarRestBeatContainerGlyph inherits pendingEffectOverflows as an empty array', () => { + const c = new MultiBarRestBeatContainerGlyph(); + expect(c.pendingEffectOverflows.length).toBe(0); + expect(c.beatId).toBe(-1); + }); + + it('pendingEffectOverflows accepts push and preserves insertion order', () => { + const c = new MultiBarRestBeatContainerGlyph(); + const first: BeatEffectOverflow = { minY: -10, maxY: 5 }; + const second: BeatEffectOverflow = { minY: -3, maxY: 12 }; + c.pendingEffectOverflows.push(first); + c.pendingEffectOverflows.push(second); + + expect(c.pendingEffectOverflows.length).toBe(2); + expect(c.pendingEffectOverflows[0].minY).toBe(-10); + expect(c.pendingEffectOverflows[0].maxY).toBe(5); + expect(c.pendingEffectOverflows[1].minY).toBe(-3); + expect(c.pendingEffectOverflows[1].maxY).toBe(12); + }); + + it('prepareForOverflowPass drains across producer cycles so the list does not grow', () => { + const c = new MultiBarRestBeatContainerGlyph(); + const cycle1: BeatEffectOverflow[] = [ + { minY: -10, maxY: 5 }, + { minY: -3, maxY: 12 } + ]; + + // Cycle 1 producer pass: drain (no-op on first entry), then push. + c.prepareForOverflowPass(); + for (const e of cycle1) { + c.pendingEffectOverflows.push(e); + } + expect(c.pendingEffectOverflows.length).toBe(2); + + // Cycle 2 producer pass: drain must clear before re-push. + const cycle2: BeatEffectOverflow[] = [ + { minY: -10, maxY: 5 }, + { minY: -3, maxY: 12 } + ]; + c.prepareForOverflowPass(); + expect(c.pendingEffectOverflows.length).toBe(0); + for (const e of cycle2) { + c.pendingEffectOverflows.push(e); + } + + // Without the drain, the array would grow to length 4 across cycles. + expect(c.pendingEffectOverflows.length).toBe(2); + expect(c.pendingEffectOverflows[0].minY).toBe(-10); + expect(c.pendingEffectOverflows[1].maxY).toBe(12); + }); +}); diff --git a/packages/alphatab/test/rendering/ScaleToWidthCallCount.test.ts b/packages/alphatab/test/rendering/ScaleToWidthCallCount.test.ts new file mode 100644 index 000000000..15ce493cb --- /dev/null +++ b/packages/alphatab/test/rendering/ScaleToWidthCallCount.test.ts @@ -0,0 +1,144 @@ +/** + * Counter test: BarRendererBase.scaleToWidth runs at most once per renderer + * per cycle. Uses prototype monkey-patching, so web-only. + * + * @target web + */ +import { AlphaTabApiBase } from '@coderline/alphatab/AlphaTabApiBase'; +import { AlphaTabError, AlphaTabErrorType } from '@coderline/alphatab/AlphaTabError'; +import { AlphaTexImporter } from '@coderline/alphatab/importer/AlphaTexImporter'; +import { ByteBuffer } from '@coderline/alphatab/io/ByteBuffer'; +import { LayoutMode } from '@coderline/alphatab/LayoutMode'; +import { JsonConverter } from '@coderline/alphatab/model/JsonConverter'; +import type { Score } from '@coderline/alphatab/model/Score'; +import { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; +import type { ScoreRenderer } from '@coderline/alphatab/rendering/ScoreRenderer'; +import type { ScoreRendererWrapper } from '@coderline/alphatab/rendering/ScoreRendererWrapper'; +import type { StaffSystem } from '@coderline/alphatab/rendering/staves/StaffSystem'; +import { Settings } from '@coderline/alphatab/Settings'; +import { describe, expect, it } from 'vitest'; +import { TestUiFacade } from '../visualTests/TestUiFacade'; +import { VisualTestHelper } from '../visualTests/VisualTestHelper'; + +/** + * @record + * @internal + */ +interface ScaleToWidthCallCounts { + counts: Map; + renderers: BarRendererBase[]; +} + +/** + * @internal + */ +class ScaleToWidthCallCountHelper { + public static async loadScore(tex: string): Promise { + const settings = new Settings(); + const importer = new AlphaTexImporter(); + importer.init(ByteBuffer.fromString(tex), settings); + return importer.readScore(); + } + + public static collectRenderers(api: AlphaTabApiBase): BarRendererBase[] { + const wrapper = api.renderer as unknown as ScoreRendererWrapper; + const inner = wrapper.instance as unknown as ScoreRenderer; + // VerticalLayoutBase exposes `systems: StaffSystem[]`; HorizontalScreenLayout + // exposes a single private `_system: StaffSystem | null`. Try both. + const layout = inner.layout as unknown as Record; + const systemsArray = layout.systems as StaffSystem[] | undefined; + const singleSystem = layout._system as StaffSystem | null | undefined; + const systems: StaffSystem[] = systemsArray + ? [...systemsArray] + : singleSystem + ? [singleSystem] + : []; + const out: BarRendererBase[] = []; + for (const s of systems) { + for (const g of s.staves) { + for (const staff of g.staves) { + for (const r of staff.barRenderers) { + out.push(r); + } + } + } + } + return out; + } + + public static async renderAndCount( + tex: string, + width: number, + layoutMode: LayoutMode + ): Promise { + await VisualTestHelper.prepareAlphaSkia(); + const score = await ScaleToWidthCallCountHelper.loadScore(tex); + const settings = new Settings(); + settings.display.layoutMode = layoutMode; + VisualTestHelper.prepareSettingsForTest(settings); + + const counts = new Map(); + const originalScaleToWidth = BarRendererBase.prototype.scaleToWidth; + BarRendererBase.prototype.scaleToWidth = function (this: BarRendererBase, w: number): void { + counts.set(this, (counts.get(this) ?? 0) + 1); + originalScaleToWidth.call(this, w); + }; + + const uiFacade = new TestUiFacade(); + uiFacade.rootContainer.width = width; + const api = new AlphaTabApiBase(uiFacade, settings); + + try { + await new Promise((resolve, reject) => { + api.renderer.postRenderFinished.on(() => resolve()); + api.error.on(e => { + reject( + new AlphaTabError(AlphaTabErrorType.General, `Render failed (${e.message})`, e) + ); + }); + const renderScore = JsonConverter.jsObjectToScore(JsonConverter.scoreToJsObject(score), settings); + api.renderScore(renderScore, [0]); + setTimeout(() => reject(new Error('render timed out')), 5000); + }); + const renderers = ScaleToWidthCallCountHelper.collectRenderers(api); + return { counts, renderers }; + } finally { + BarRendererBase.prototype.scaleToWidth = originalScaleToWidth; + api.destroy(); + } + } +} + +describe('ScaleToWidthCallCount', () => { + it('§H Step 7: vertical layout — scaleToWidth runs exactly once per renderer per cycle', async () => { + const tex = ` + \\track "T1" + \\staff {score} + C4.4 *4 | C4.4 *4 | C4.4 *4 | C4.4 *4 + `; + const result = await ScaleToWidthCallCountHelper.renderAndCount(tex, 1500, LayoutMode.Page); + const counts = result.counts; + const renderers = result.renderers; + + expect(renderers.length).toBeGreaterThan(0); + for (const r of renderers) { + expect(counts.get(r) ?? 0).toBe(1); + } + }); + + it('§H Step 7: horizontal layout — scaleToWidth runs exactly once per renderer per cycle', async () => { + const tex = ` + \\track "T1" + \\staff {score} + C4.4 *4 | C4.4 *4 | C4.4 *4 + `; + const result = await ScaleToWidthCallCountHelper.renderAndCount(tex, 1500, LayoutMode.Horizontal); + const counts = result.counts; + const renderers = result.renderers; + + expect(renderers.length).toBeGreaterThan(0); + for (const r of renderers) { + expect(counts.get(r) ?? 0).toBe(1); + } + }); +}); diff --git a/packages/alphatab/test/rendering/skyline/BarLocalSkyline.test.ts b/packages/alphatab/test/rendering/skyline/BarLocalSkyline.test.ts new file mode 100644 index 000000000..266bdd395 --- /dev/null +++ b/packages/alphatab/test/rendering/skyline/BarLocalSkyline.test.ts @@ -0,0 +1,75 @@ +import { BarLocalSkyline, StaffSide } from '@coderline/alphatab/rendering/skyline/BarLocalSkyline'; +import { SkylineSegmentPool } from '@coderline/alphatab/rendering/skyline/SkylineSegmentPool'; +import { describe, expect, it } from 'vitest'; + +/** + * @internal + */ +class Wrapper { + public readonly bar: BarLocalSkyline; + public readonly pool: SkylineSegmentPool; + public constructor(xMin: number = 0, xMax: number = 100) { + this.pool = new SkylineSegmentPool(); + this.bar = new BarLocalSkyline(xMin, xMax, this.pool); + } +} + +/** + * @internal + */ +class BarLocalSkylineFixtures { + public static newWrapper(xMin: number = 0, xMax: number = 100): Wrapper { + return new Wrapper(xMin, xMax); + } +} + +describe('BarLocalSkyline — construction', () => { + it('builds two flat skylines over the bar x-range', () => { + const w: Wrapper = BarLocalSkylineFixtures.newWrapper(0, 50); + expect(w.bar.upSky.xMin).toBe(0); + expect(w.bar.upSky.xMax).toBe(50); + expect(w.bar.downSky.xMin).toBe(0); + expect(w.bar.downSky.xMax).toBe(50); + expect(w.bar.upSky.maxHeight()).toBe(0); + expect(w.bar.downSky.maxHeight()).toBe(0); + }); +}); + +describe('BarLocalSkyline — side-aware routing', () => { + it('insertPlaced(Top) only affects upSky', () => { + const w: Wrapper = BarLocalSkylineFixtures.newWrapper(); + w.bar.insertPlaced(StaffSide.Top, 10, 30, 5, 0); + expect(w.bar.upSky.maxHeight()).toBe(5); + expect(w.bar.downSky.maxHeight()).toBe(0); + }); + + it('insertPlaced(Bottom) only affects downSky', () => { + const w: Wrapper = BarLocalSkylineFixtures.newWrapper(); + w.bar.insertPlaced(StaffSide.Bottom, 10, 30, 4, 0); + expect(w.bar.downSky.maxHeight()).toBe(4); + expect(w.bar.upSky.maxHeight()).toBe(0); + }); + + it('top and bottom are mutually independent', () => { + const w: Wrapper = BarLocalSkylineFixtures.newWrapper(); + w.bar.insertPlaced(StaffSide.Top, 20, 60, 7, 0); + w.bar.insertPlaced(StaffSide.Bottom, 20, 60, 2, 0); + expect(w.bar.upSky.placeAbove(30, 50, 1, 0)).toBe(7); + expect(w.bar.downSky.placeBelow(30, 50, 1, 0)).toBe(2); + }); +}); + +describe('BarLocalSkyline — reset', () => { + it('returns child Skylines to baseline + reuses pool segments', () => { + const w: Wrapper = BarLocalSkylineFixtures.newWrapper(); + w.bar.insertPlaced(StaffSide.Top, 10, 30, 4, 0); + w.bar.insertPlaced(StaffSide.Bottom, 50, 70, 3, 0); + const grownBefore: number = w.pool.grownCount; + w.bar.reset(); + expect(w.bar.upSky.maxHeight()).toBe(0); + expect(w.bar.downSky.maxHeight()).toBe(0); + w.bar.insertPlaced(StaffSide.Top, 20, 80, 6, 0); + expect(w.bar.upSky.placeAbove(40, 60, 1, 0)).toBe(6); + expect(w.pool.grownCount).toBe(grownBefore); + }); +}); diff --git a/packages/alphatab/test/rendering/skyline/Skyline.test.ts b/packages/alphatab/test/rendering/skyline/Skyline.test.ts new file mode 100644 index 000000000..195359915 --- /dev/null +++ b/packages/alphatab/test/rendering/skyline/Skyline.test.ts @@ -0,0 +1,331 @@ +import { Skyline } from '@coderline/alphatab/rendering/skyline/Skyline'; +import { SkylineSegmentPool } from '@coderline/alphatab/rendering/skyline/SkylineSegmentPool'; +import { describe, expect, it } from 'vitest'; + +/** + * @record + * @internal + */ +interface SkylineSegmentExpect { + s: number; + e: number; + h: number; +} + +/** + * @internal + */ +class SkylineFixtures { + public static newSkyline(xMin: number, xMax: number): Skyline { + const pool: SkylineSegmentPool = new SkylineSegmentPool(); + return new Skyline(xMin, xMax, pool); + } +} + +describe('Skyline — construction', () => { + it('starts with two segments: baseline at xMin + sentinel at xMax', () => { + const sky: Skyline = SkylineFixtures.newSkyline(0, 100); + expect(sky.xMin).toBe(0); + expect(sky.xMax).toBe(100); + expect(sky.segmentCount).toBe(1); + expect(sky.maxHeight()).toBe(0); + }); + + it('empty placeAbove on a fresh skyline returns pad (zero clearance for pad=0)', () => { + const sky: Skyline = SkylineFixtures.newSkyline(0, 100); + expect(sky.placeAbove(10, 20, 1, 0)).toBe(0); + expect(sky.placeAbove(10, 20, 1, 0.5)).toBe(0.5); + }); +}); + +describe('Skyline — insert + placeAbove', () => { + it('a single insert raises the skyline in its range', () => { + const sky: Skyline = SkylineFixtures.newSkyline(0, 100); + sky.insert(10, 30, 5, 0); + expect(sky.placeAbove(15, 25, 1, 0)).toBe(5); + expect(sky.placeAbove(40, 60, 1, 0)).toBe(0); + }); + + it('placeAbove adds pad clearance on top of max-in-range', () => { + const sky: Skyline = SkylineFixtures.newSkyline(0, 100); + sky.insert(10, 30, 5, 0); + expect(sky.placeAbove(15, 25, 1, 0.5)).toBe(5.5); + }); + + it('placeAbove pad does NOT widen the x-axis query range', () => { + // The `pad` parameter governs vertical clearance only — the + // horizontal collision range is the band's actual x extent. + // A neighbouring inserted rect outside that range stays + // invisible regardless of pad size, because the rhythmic + // spacing solver does not reserve a horizontal pad gap. + const sky: Skyline = SkylineFixtures.newSkyline(0, 100); + sky.insert(10, 30, 5, 0); + expect(sky.placeAbove(35, 40, 1, 0)).toBe(0); + expect(sky.placeAbove(35, 40, 1, 10)).toBe(10); + }); + + it('multiple non-overlapping inserts coexist', () => { + const sky: Skyline = SkylineFixtures.newSkyline(0, 100); + sky.insert(10, 30, 5, 0); + sky.insert(50, 70, 3, 0); + expect(sky.placeAbove(15, 25, 1, 0)).toBe(5); + expect(sky.placeAbove(55, 65, 1, 0)).toBe(3); + expect(sky.placeAbove(35, 45, 1, 0)).toBe(0); + }); + + it('overlapping inserts take the per-x max', () => { + const sky: Skyline = SkylineFixtures.newSkyline(0, 100); + sky.insert(10, 40, 5, 0); + sky.insert(30, 60, 8, 0); + expect(sky.placeAbove(15, 25, 1, 0)).toBe(5); + expect(sky.placeAbove(35, 38, 1, 0)).toBe(8); + expect(sky.placeAbove(45, 55, 1, 0)).toBe(8); + expect(sky.placeAbove(80, 90, 1, 0)).toBe(0); + }); + + it('insert below current height is a no-op (per-x max semantics)', () => { + const sky: Skyline = SkylineFixtures.newSkyline(0, 100); + sky.insert(10, 30, 5, 0); + sky.insert(10, 30, 2, 0); + expect(sky.placeAbove(15, 25, 1, 0)).toBe(5); + }); + + it('non-positive newHeight is a no-op', () => { + const sky: Skyline = SkylineFixtures.newSkyline(0, 100); + sky.insert(10, 30, 0, 0); + sky.insert(10, 30, -5, 0); + expect(sky.maxHeight()).toBe(0); + }); + + it('insert outside the skyline range is clamped to [xMin, xMax]', () => { + const sky: Skyline = SkylineFixtures.newSkyline(0, 100); + sky.insert(-50, 200, 7, 0); + expect(sky.placeAbove(0, 100, 1, 0)).toBe(7); + }); +}); + +describe('Skyline — placeBelow', () => { + it('placeBelow runs the same algorithm as placeAbove', () => { + const sky: Skyline = SkylineFixtures.newSkyline(0, 100); + sky.insert(10, 30, 4, 0); + expect(sky.placeBelow(15, 25, 1, 0)).toBe(4); + expect(sky.placeBelow(15, 25, 1, 0.5)).toBe(4.5); + }); +}); + +describe('Skyline — adjacency merge', () => { + it('two contiguous same-height inserts collapse into one segment', () => { + const sky: Skyline = SkylineFixtures.newSkyline(0, 100); + sky.insert(10, 30, 5, 0); + sky.insert(30, 50, 5, 0); + expect(sky.segmentCount).toBe(3); + expect(sky.placeAbove(40, 45, 1, 0)).toBe(5); + }); + + it('three overlapping inserts at same height collapse', () => { + const sky: Skyline = SkylineFixtures.newSkyline(0, 100); + sky.insert(10, 30, 5, 0); + sky.insert(20, 40, 5, 0); + sky.insert(35, 50, 5, 0); + expect(sky.segmentCount).toBe(3); + expect(sky.placeAbove(15, 45, 1, 0)).toBe(5); + }); +}); + +describe('Skyline — segment splitting at insert boundaries', () => { + it('insert splits existing segments at xStart and xEnd', () => { + const sky: Skyline = SkylineFixtures.newSkyline(0, 100); + sky.insert(0, 100, 3, 0); + expect(sky.segmentCount).toBe(1); + sky.insert(20, 40, 7, 0); + expect(sky.segmentCount).toBe(3); + expect(sky.placeAbove(10, 15, 1, 0)).toBe(3); + expect(sky.placeAbove(25, 35, 1, 0)).toBe(7); + expect(sky.placeAbove(50, 60, 1, 0)).toBe(3); + }); +}); + +describe('Skyline — maxHeight', () => { + it('returns the largest segment height', () => { + const sky: Skyline = SkylineFixtures.newSkyline(0, 100); + sky.insert(10, 30, 5, 0); + sky.insert(50, 70, 12, 0); + sky.insert(80, 90, 3, 0); + expect(sky.maxHeight()).toBe(12); + }); + + it('returns 0 for an empty skyline', () => { + const sky: Skyline = SkylineFixtures.newSkyline(0, 100); + expect(sky.maxHeight()).toBe(0); + }); +}); + +describe('Skyline — union', () => { + it('raises self to per-x max of self and other', () => { + const a: Skyline = SkylineFixtures.newSkyline(0, 100); + const b: Skyline = SkylineFixtures.newSkyline(0, 100); + a.insert(10, 40, 5, 0); + b.insert(30, 60, 8, 0); + a.union(b); + expect(a.placeAbove(15, 25, 1, 0)).toBe(5); + expect(a.placeAbove(35, 38, 1, 0)).toBe(8); + expect(a.placeAbove(45, 55, 1, 0)).toBe(8); + expect(a.placeAbove(65, 75, 1, 0)).toBe(0); + }); + + it('union of an empty skyline is a no-op', () => { + const a: Skyline = SkylineFixtures.newSkyline(0, 100); + a.insert(10, 30, 5, 0); + const empty: Skyline = SkylineFixtures.newSkyline(0, 100); + a.union(empty); + expect(a.placeAbove(15, 25, 1, 0)).toBe(5); + expect(a.maxHeight()).toBe(5); + }); +}); + +describe('Skyline — unionShifted', () => { + it('shifts other by dx and takes per-x max with self', () => { + const a: Skyline = SkylineFixtures.newSkyline(0, 100); + const b: Skyline = SkylineFixtures.newSkyline(0, 50); + a.insert(10, 40, 5, 0); + b.insert(0, 20, 8, 0); + // dx = 30: b's [0,20)@8 lands on a as [30,50)@8. + a.unionShifted(b, 30); + expect(a.placeAbove(15, 25, 1, 0)).toBe(5); + expect(a.placeAbove(30, 40, 1, 0)).toBe(8); // overlap of a's [10,40)@5 and shifted b's [30,50)@8 → 8 + expect(a.placeAbove(40, 50, 1, 0)).toBe(8); + expect(a.placeAbove(60, 80, 1, 0)).toBe(0); + }); + + it('clamps shifted segments to [xMin, xMax]', () => { + const a: Skyline = SkylineFixtures.newSkyline(0, 100); + const b: Skyline = SkylineFixtures.newSkyline(0, 100); + b.insert(10, 30, 6, 0); + // dx = -20 → b's [10,30)@6 maps to a's [-10, 10)@6; clamp to [0, 10). + a.unionShifted(b, -20); + expect(a.placeAbove(0, 10, 1, 0)).toBe(6); + expect(a.placeAbove(10, 20, 1, 0)).toBe(0); + }); + + it('drops shifted segments that fall fully outside the target range', () => { + const a: Skyline = SkylineFixtures.newSkyline(0, 100); + const b: Skyline = SkylineFixtures.newSkyline(0, 50); + b.insert(0, 50, 9, 0); + // dx = 200 → entire shifted other lies past xMax=100; no-op. + a.unionShifted(b, 200); + expect(a.maxHeight()).toBe(0); + expect(a.segmentCount).toBe(1); + }); + + it('empty other is a no-op', () => { + const a: Skyline = SkylineFixtures.newSkyline(0, 100); + a.insert(10, 30, 5, 0); + const empty: Skyline = SkylineFixtures.newSkyline(0, 100); + a.unionShifted(empty, 7); + expect(a.placeAbove(15, 25, 1, 0)).toBe(5); + expect(a.maxHeight()).toBe(5); + }); + + it('repeated unions accumulate correctly (per-x max)', () => { + const a: Skyline = SkylineFixtures.newSkyline(0, 100); + const b: Skyline = SkylineFixtures.newSkyline(0, 20); + b.insert(0, 20, 4, 0); + a.unionShifted(b, 0); // contributes [0,20)@4 + a.unionShifted(b, 30); // contributes [30,50)@4 + a.unionShifted(b, 60); // contributes [60,80)@4 + expect(a.placeAbove(0, 20, 1, 0)).toBe(4); + expect(a.placeAbove(20, 30, 1, 0)).toBe(0); + expect(a.placeAbove(30, 50, 1, 0)).toBe(4); + expect(a.placeAbove(50, 60, 1, 0)).toBe(0); + expect(a.placeAbove(60, 80, 1, 0)).toBe(4); + }); + + it('produces canonical segments (no adjacent same-height entries)', () => { + const a: Skyline = SkylineFixtures.newSkyline(0, 100); + const b: Skyline = SkylineFixtures.newSkyline(0, 50); + a.insert(10, 30, 5, 0); + b.insert(0, 20, 5, 0); + // b shifted by 30 -> [30,50)@5. Adjacent to a's [10,30)@5 → must coalesce + // into a single [10,50)@5 segment. + a.unionShifted(b, 30); + // segmentCount counts non-sentinel pieces. A canonical layout for this + // result is: [{0,0},{10,5},{50,0},{100,0sentinel}] => 3 visible pieces + // (the [50,100)@0 tail is one segment before sentinel). + expect(a.segmentCount).toBe(3); + expect(a.placeAbove(15, 25, 1, 0)).toBe(5); + expect(a.placeAbove(30, 50, 1, 0)).toBe(5); + expect(a.placeAbove(50, 60, 1, 0)).toBe(0); + }); + + it('index-based segment accessors mirror forEachSegment', () => { + const sky: Skyline = SkylineFixtures.newSkyline(0, 100); + sky.insert(10, 30, 5, 0); + sky.insert(50, 70, 8, 0); + + const fromCb: SkylineSegmentExpect[] = []; + sky.forEachSegment((xStart: number, xEnd: number, height: number) => { + fromCb.push({ s: xStart, e: xEnd, h: height }); + }); + const fromIdx: SkylineSegmentExpect[] = []; + for (let i: number = 0; i < sky.segmentCount; i = i + 1) { + fromIdx.push({ s: sky.segmentXStart(i), e: sky.segmentXEnd(i), h: sky.segmentHeight(i) }); + } + // Per-element scalar comparison (named-record arrays lack structural + // equality in the C# transpilation target — see SYNTAX.md notes on + // @record class identity). + expect(fromIdx.length).toBe(fromCb.length); + for (let i: number = 0; i < fromIdx.length; i = i + 1) { + expect(fromIdx[i].s).toBe(fromCb[i].s); + expect(fromIdx[i].e).toBe(fromCb[i].e); + expect(fromIdx[i].h).toBe(fromCb[i].h); + } + }); +}); + +describe('Skyline — reset', () => { + it('returns segments to the pool and rebuilds the baseline', () => { + const pool: SkylineSegmentPool = new SkylineSegmentPool(); + const sky: Skyline = new Skyline(0, 100, pool); + sky.insert(10, 30, 5, 0); + sky.insert(50, 70, 8, 0); + const grownBefore: number = pool.grownCount; + expect(grownBefore).toBeGreaterThan(2); + sky.reset(); + expect(sky.maxHeight()).toBe(0); + expect(sky.segmentCount).toBe(1); + sky.insert(20, 80, 6, 0); + expect(sky.placeAbove(40, 60, 1, 0)).toBe(6); + expect(pool.grownCount).toBe(grownBefore); + }); + + it('reset is idempotent', () => { + const sky: Skyline = SkylineFixtures.newSkyline(0, 100); + sky.insert(10, 30, 5, 0); + sky.reset(); + sky.reset(); + expect(sky.maxHeight()).toBe(0); + expect(sky.segmentCount).toBe(1); + }); +}); + +describe('SkylineSegmentPool class', () => { + it('acquires a fresh segment with zeroed fields', () => { + const pool: SkylineSegmentPool = new SkylineSegmentPool(); + const a = pool.acquire(); + expect(a.xStart).toBe(0); + expect(a.height).toBe(0); + }); + + it('reuses released segments before growing', () => { + const pool: SkylineSegmentPool = new SkylineSegmentPool(); + const a = pool.acquire(); + a.xStart = 5; + a.height = 7; + pool.release(a); + const b = pool.acquire(); + expect(b).toBe(a); + expect(b.xStart).toBe(0); + expect(b.height).toBe(0); + expect(pool.grownCount).toBe(1); + }); +}); diff --git a/packages/alphatab/test/rendering/skyline/StaffSystemSkyline.test.ts b/packages/alphatab/test/rendering/skyline/StaffSystemSkyline.test.ts new file mode 100644 index 000000000..1ee4c0dd9 --- /dev/null +++ b/packages/alphatab/test/rendering/skyline/StaffSystemSkyline.test.ts @@ -0,0 +1,72 @@ +import { StaffSide } from '@coderline/alphatab/rendering/skyline/BarLocalSkyline'; +import { SkylineSegmentPool } from '@coderline/alphatab/rendering/skyline/SkylineSegmentPool'; +import { StaffSystemSkyline } from '@coderline/alphatab/rendering/skyline/StaffSystemSkyline'; +import { describe, expect, it } from 'vitest'; + +/** + * @internal + */ +class SysSkyHandle { + public readonly sky: StaffSystemSkyline; + public readonly pool: SkylineSegmentPool; + public constructor(sky: StaffSystemSkyline, pool: SkylineSegmentPool) { + this.sky = sky; + this.pool = pool; + } +} + +/** + * @internal + */ +class StaffSystemSkylineFixtures { + public static newSysSky( + staffIndex: number = 0, + systemIndex: number = 0, + xMin: number = 0, + xMax: number = 200 + ): SysSkyHandle { + const pool: SkylineSegmentPool = new SkylineSegmentPool(); + const sky: StaffSystemSkyline = new StaffSystemSkyline(staffIndex, systemIndex, xMin, xMax, pool); + return new SysSkyHandle(sky, pool); + } +} + +describe('StaffSystemSkyline — construction', () => { + it('records staffIndex + systemIndex and starts with flat skylines', () => { + const h: SysSkyHandle = StaffSystemSkylineFixtures.newSysSky(2, 5, 0, 80); + const sky: StaffSystemSkyline = h.sky; + expect(sky.staffIndex).toBe(2); + expect(sky.systemIndex).toBe(5); + expect(sky.upSky.xMin).toBe(0); + expect(sky.upSky.xMax).toBe(80); + expect(sky.downSky.xMin).toBe(0); + expect(sky.downSky.xMax).toBe(80); + expect(sky.upSky.maxHeight()).toBe(0); + expect(sky.downSky.maxHeight()).toBe(0); + }); +}); + +describe('StaffSystemSkyline — insertPlaced + routing', () => { + it('side-aware insertPlaced raises only the addressed skyline', () => { + const h: SysSkyHandle = StaffSystemSkylineFixtures.newSysSky(); + const sky: StaffSystemSkyline = h.sky; + sky.insertPlaced(StaffSide.Top, 20, 80, 5, 0); + sky.insertPlaced(StaffSide.Bottom, 40, 60, 4, 0); + expect(sky.upSky.maxHeight()).toBe(5); + expect(sky.downSky.maxHeight()).toBe(4); + }); +}); + +describe('StaffSystemSkyline — reset', () => { + it('clears state for reuse', () => { + const h: SysSkyHandle = StaffSystemSkylineFixtures.newSysSky(); + const sky: StaffSystemSkyline = h.sky; + sky.insertPlaced(StaffSide.Top, 20, 40, 6, 0); + sky.insertPlaced(StaffSide.Bottom, 50, 70, 3, 0); + sky.reset(); + expect(sky.upSky.maxHeight()).toBe(0); + expect(sky.downSky.maxHeight()).toBe(0); + sky.insertPlaced(StaffSide.Top, 30, 70, 4, 0); + expect(sky.upSky.maxHeight()).toBe(4); + }); +}); diff --git a/packages/alphatab/test/visualTests/PixelMatch.ts b/packages/alphatab/test/visualTests/PixelMatch.ts index c014a50be..c8312d682 100644 --- a/packages/alphatab/test/visualTests/PixelMatch.ts +++ b/packages/alphatab/test/visualTests/PixelMatch.ts @@ -36,28 +36,6 @@ export class PixelMatchOptions { * @default 0.1 */ public alpha: number | null = null; - /** - * The color of anti-aliased pixels in the diff output. - * @default [255, 255, 0] - */ - public aaColor: number[] | null = null; - /** - * The color of differing pixels in the diff output. - * @default [255, 0, 0] - */ - public diffColor: number[] | null = null; - /** - * An alternative color to use for dark on light differences to differentiate between "added" and "removed" parts. - * If not provided, all differing pixels use the color specified by `diffColor`. - * @default null - */ - public diffColorAlt: number[] | null = null; - /** - * Draw the diff over a transparent background (a mask), rather than over the original image. - * Will not draw anti-aliased pixels (if detected) - * @default false - */ - public diffMask: boolean | null = null; } /** @@ -86,21 +64,17 @@ export class PixelMatch { o.threshold = 0.1; // matching threshold (0 to 1); smaller is more sensitive o.includeAA = false; // whether to skip anti-aliasing detection o.alpha = 0.1; // opacity of original image in diff ouput - o.aaColor = [255, 255, 0]; // color of anti-aliased pixels in diff output - o.diffColor = [255, 0, 0]; // color of different pixels in diff output - o.diffMask = false; // draw the diff over a transparent background (a mask) return o; } static match( img1: Uint8Array, img2: Uint8Array, - output: Uint8Array, width: number, height: number, options: PixelMatchOptions ): PixelMatchResult { - if (img1.length !== img2.length || (output && output.length !== img1.length)) { + if (img1.length !== img2.length) { throw new Error(`Image sizes do not match. ${img1.length} !== ${img2.length}`); } @@ -108,11 +82,7 @@ export class PixelMatch { throw new Error('Image data size does not match width/height.'); } - options.aaColor = options.aaColor ?? PixelMatch.defaultOptions.aaColor; options.alpha = options.alpha ?? PixelMatch.defaultOptions.alpha; - options.diffColor = options.diffColor ?? PixelMatch.defaultOptions.diffColor; - options.diffColorAlt = options.diffColorAlt ?? PixelMatch.defaultOptions.diffColorAlt; - options.diffMask = options.diffMask ?? PixelMatch.defaultOptions.diffMask; options.includeAA = options.includeAA ?? PixelMatch.defaultOptions.includeAA; options.threshold = options.threshold ?? PixelMatch.defaultOptions.threshold; @@ -141,12 +111,6 @@ export class PixelMatch { } } if (identical) { - // fast path if identical - if (output && !options.diffMask) { - for (let i = 0; i < len; i++) { - PixelMatch.drawGrayPixel(img1, 4 * i, options.alpha!, output); - } - } return new PixelMatchResult(len, 0, transparentPixels); } @@ -157,12 +121,6 @@ export class PixelMatch { const maxDelta = 35215 * options.threshold! * options.threshold!; let diff = 0; - const aaR = options.aaColor![0]; - const aaG = options.aaColor![1]; - const aaB = options.aaColor![2]; - const diffR = options.diffColor![0]; - const diffG = options.diffColor![1]; - const diffB = options.diffColor![2]; // compare each pixel of one image against the other one for (let y = 0; y < height; y++) { @@ -185,21 +143,10 @@ export class PixelMatch { ) { // one of the pixels is anti-aliasing; draw as yellow and do not count as difference // note that we do not include such pixels in a mask - if (output && !options.diffMask) { - PixelMatch.drawPixel(output, pos, aaR, aaG, aaB); - } } else { // found substantial difference not caused by anti-aliasing; draw it as red - if (output) { - PixelMatch.drawPixel(output, pos, diffR, diffG, diffB); - } diff++; } - } else if (output) { - // pixels are similar; draw background as grayscale image blended with white - if (!options.diffMask) { - PixelMatch.drawGrayPixel(img1, pos, options.alpha!, output); - } } } } diff --git a/packages/alphatab/test/visualTests/VisualTestHelper.ts b/packages/alphatab/test/visualTests/VisualTestHelper.ts index 996744259..5691c9054 100644 --- a/packages/alphatab/test/visualTests/VisualTestHelper.ts +++ b/packages/alphatab/test/visualTests/VisualTestHelper.ts @@ -342,7 +342,6 @@ export class VisualTestHelper { const expectedImageData = expected.readPixels()!; // do visual comparison - const diffImageData = new ArrayBuffer(actualImageData.byteLength); pass = true; errorMessage = ''; @@ -350,13 +349,11 @@ export class VisualTestHelper { const pixelMatchOptions = new PixelMatchOptions(); pixelMatchOptions.threshold = 0.3; pixelMatchOptions.includeAA = false; - pixelMatchOptions.diffMask = true; pixelMatchOptions.alpha = 1; const match = PixelMatch.match( new Uint8Array(expectedImageData), new Uint8Array(actualImageData), - new Uint8Array(diffImageData), expected.width, expected.height, pixelMatchOptions @@ -374,9 +371,7 @@ export class VisualTestHelper { const percentDifferenceText = percentDifference.toFixed(2); errorMessage = `Difference between original and new image is too big: ${match.differentPixels}/${totalPixels} (${percentDifferenceText}%)`; - using diffPng = AlphaSkiaImage.fromPixels(actual.width, actual.height, diffImageData)!; - - await VisualTestHelper.saveFiles(expectedFileName, oldActual, diffPng); + await VisualTestHelper.saveFiles(expectedFileName, oldActual); } if (sizeMismatch) { @@ -390,7 +385,7 @@ export class VisualTestHelper { } else { pass = false; errorMessage = `Missing reference image file${expectedFileName}`; - await VisualTestHelper.saveFiles(expectedFileName, oldActual, undefined); + await VisualTestHelper.saveFiles(expectedFileName, oldActual); } if (!pass) { @@ -399,18 +394,7 @@ export class VisualTestHelper { await VisualTestHelper.deleteFiles(expectedFileName); } - static async saveFiles( - expectedFilePath: string, - actual: AlphaSkiaImage, - diff: AlphaSkiaImage | undefined - ): Promise { - if (diff) { - const diffData = diff.toPng()!; - - const diffFileName = TestPlatform.changeExtension(expectedFilePath, '.diff.png'); - await TestPlatform.saveFile(diffFileName, new Uint8Array(diffData)); - } - + static async saveFiles(expectedFilePath: string, actual: AlphaSkiaImage): Promise { const actualData = actual.toPng()!; const actualFile = TestPlatform.changeExtension(expectedFilePath, '.new.png'); await TestPlatform.saveFile(actualFile, new Uint8Array(actualData)); diff --git a/packages/alphatab/test/visualTests/features/Layout.test.ts b/packages/alphatab/test/visualTests/features/Layout.test.ts index c2a12bac7..9b20f84b3 100644 --- a/packages/alphatab/test/visualTests/features/Layout.test.ts +++ b/packages/alphatab/test/visualTests/features/Layout.test.ts @@ -330,6 +330,66 @@ describe('LayoutTests', () => { ); }); + // §G.5 — Mid-system visibility flip: hideEmptyStaves + multi-staff where a + // staff is invisible at the start of a system but a later bar in the same + // system has content and flips it visible. The v5 §G.5 invariant requires + // `_calculateAccoladeSpacing` to recompute when this flip happens so the + // brace and per-staff Y positions reflect the post-flip visibility. Closes + // B.5 (firstVisibleStaff first-call-only) and B.23 (staff.height locked too + // early). System 2 (bars 5-8) flips staff 2 visible at bar 8. + it('ghost-staff-visibility', async () => { + await VisualTestHelper.runVisualTestTex( + ` + \\hideEmptyStaves + \\defaultSystemsLayout 4 + \\track "T1" + \\staff {score} + C4.4 *4 | r.1 | r.1 | r.1 | + r.1 | r.1 | r.1 | r.1 | + C4.1 | + \\staff {score} + \\clef C3 + r.1 | r.1 | r.1 | r.1 | + r.1 | r.1 | r.1 | c4.1 | + r.1 | + `, + 'test-data/visual-tests/layout/ghost-staff-visibility.png', + undefined, + o => { + o.tracks = o.score.tracks.map(t => t.index); + o.settings.display.layoutMode = LayoutMode.Parchment; + } + ); + }); + + // §G.7 — Accolade reflects post-add visibility: multi-track score where one + // track has content only on specific bars. The brace must scale to cover + // visible staves on each system; on systems where a track's bars are all + // rests, `hideEmptyStaves` makes its staff invisible and the brace shrinks. + // The visibility-fingerprint gate triggers the recompute that produces the + // correct accolade width per system. + it('accolade-on-revert', async () => { + await VisualTestHelper.runVisualTestTex( + ` + \\hideEmptyStaves + \\showSingleStaffBrackets + \\defaultSystemsLayout 3 + \\track "T1" + C4.4 *4 | C4.4 *4 | C4.4 *4 | + C4.4 *4 | C4.4 *4 | C4.4 *4 + \\track "T2" + C4.4 *4 | C4.4 *4 | C4.4 *4 | + r.1 | r.1 | r.1 + `, + 'test-data/visual-tests/layout/accolade-on-revert.png', + undefined, + o => { + o.tracks = o.score.tracks.map(t => t.index); + o.settings.display.layoutMode = LayoutMode.Parchment; + } + ); + }); + describe('barnumberdisplay', () => { describe('stylesheet', () => { it('all', async () => diff --git a/packages/alphatab/test/visualTests/skyline/PlacementInspector.test.ts b/packages/alphatab/test/visualTests/skyline/PlacementInspector.test.ts new file mode 100644 index 000000000..8c35cf7fd --- /dev/null +++ b/packages/alphatab/test/visualTests/skyline/PlacementInspector.test.ts @@ -0,0 +1,42 @@ +/** + * Smoke test + live usage example for the {@link PlacementInspectorHelper}. + */ +import { Logger } from '@coderline/alphatab/Logger'; +import { describe, expect, it } from 'vitest'; +import { PlacementInspectorHelper } from './PlacementInspectorHelper'; + +describe('PlacementInspectorHelperTests', () => { + it('renders a report for the slur+trill repro case', async () => { + const tex = `\\voice + C4 {tempo 120} +\\voice +r`; + const report = await PlacementInspectorHelper.inspectPlacement(tex); + Logger.info('PlacementInspector', `\n${report}\n`); + expect(report).toContain('inspectPlacement tex='); + }); + + it('emits the structural fields the bugfix workflow depends on', async () => { + const report = await PlacementInspectorHelper.inspectPlacement('\\tempo 120 . C4 {txt "A"} C4'); + // Each of these tokens is a contract with the inspector format — + // bugfix rounds grep through the output for them. + for (const token of [ + 'topOverflow=', + 'bottomOverflow=', + 'systemSkyline.upSky', + 'systemSkyline.downSky', + 'Renderer[0]', + 'barLocal.up', + 'barLocal.down', + 'topBands[', + 'bottomBands[', + 'placedMagnitude=', + 'outerEdge=', + 'band.y=', + 'xLocal=', + 'tier=' + ]) { + expect(report).toContain(token); + } + }); +}); diff --git a/packages/alphatab/test/visualTests/skyline/PlacementInspectorHelper.ts b/packages/alphatab/test/visualTests/skyline/PlacementInspectorHelper.ts new file mode 100644 index 000000000..cd55ca08c --- /dev/null +++ b/packages/alphatab/test/visualTests/skyline/PlacementInspectorHelper.ts @@ -0,0 +1,281 @@ +/** + * Test-only placement inspector. Renders a tex score via the full pipeline + * (alphaSkia + AlphaTabApiBase) and emits a human-readable textual report + * of every effect band's skyline placement decision. + * + * @internal + */ +import { AlphaTabApiBase } from '@coderline/alphatab/AlphaTabApiBase'; +import { AlphaTabError, AlphaTabErrorType } from '@coderline/alphatab/AlphaTabError'; +import { AlphaTexImporter } from '@coderline/alphatab/importer/AlphaTexImporter'; +import { ScoreLoader } from '@coderline/alphatab/importer/ScoreLoader'; +import { ByteBuffer } from '@coderline/alphatab/io/ByteBuffer'; +import { LayoutMode } from '@coderline/alphatab/LayoutMode'; +import { JsonConverter } from '@coderline/alphatab/model/JsonConverter'; +import type { Score } from '@coderline/alphatab/model/Score'; +import { NotationElement } from '@coderline/alphatab/NotationSettings'; +import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase'; +import type { EffectBand } from '@coderline/alphatab/rendering/EffectBand'; +import { EffectBarGlyphSizing } from '@coderline/alphatab/rendering/EffectBarGlyphSizing'; +import type { ScoreRenderer } from '@coderline/alphatab/rendering/ScoreRenderer'; +import type { ScoreRendererWrapper } from '@coderline/alphatab/rendering/ScoreRendererWrapper'; +import type { Skyline } from '@coderline/alphatab/rendering/skyline/Skyline'; +import type { RenderStaff } from '@coderline/alphatab/rendering/staves/RenderStaff'; +import type { StaffSystem } from '@coderline/alphatab/rendering/staves/StaffSystem'; +import { Settings } from '@coderline/alphatab/Settings'; +import { TestUiFacade } from '../TestUiFacade'; +import { VisualTestHelper } from '../VisualTestHelper'; +import type { ScoreLayoutInternals } from './SkylineTestHarness'; +import type { EffectBandXRange } from '@coderline/alphatab/rendering/EffectBand'; + +/** + * @internal + */ +export class PlacementInspectorHelper { + private static readonly _xRangeScratch: EffectBandXRange = { xStart: 0, xEnd: 0 }; + + public static async loadScore(tex: string): Promise { + const settings = new Settings(); + const importer = new AlphaTexImporter(); + importer.init(ByteBuffer.fromString(tex), settings); + return importer.readScore(); + } + + public static loadScoreFromBytes(bytes: Uint8Array): Score { + return ScoreLoader.loadScoreFromBytes(bytes); + } + + public static sizingName(s: EffectBarGlyphSizing): string { + switch (s) { + case EffectBarGlyphSizing.SinglePreBeat: + return 'SinglePreBeat'; + case EffectBarGlyphSizing.SingleOnBeat: + return 'SingleOnBeat'; + case EffectBarGlyphSizing.SingleOnBeatToEnd: + return 'SingleOnBeatToEnd'; + case EffectBarGlyphSizing.GroupedOnBeat: + return 'GroupedOnBeat'; + case EffectBarGlyphSizing.GroupedOnBeatToEnd: + return 'GroupedOnBeatToEnd'; + case EffectBarGlyphSizing.FullBar: + return 'FullBar'; + default: + return 'Unknown'; + } + } + + /** + * Mirrors the tier logic in + * {@link import('@coderline/alphatab/rendering/EffectSystemPlacement').EffectSystemPlacement} + * so the report shows the same classification the placement pass used. + */ + public static tier(band: EffectBand): number { + const sm = band.info.sizingMode; + const single = + sm === EffectBarGlyphSizing.SinglePreBeat || + sm === EffectBarGlyphSizing.SingleOnBeat || + sm === EffectBarGlyphSizing.SingleOnBeatToEnd; + return single && band.firstBeat === band.lastBeat ? 1 : 2; + } + + public static n(value: number, fractionDigits: number = 2): string { + if (Number.isNaN(value) || value === Number.POSITIVE_INFINITY || value === -Number.POSITIVE_INFINITY) { + return value.toString(); + } + const factor = Math.pow(10, fractionDigits); + const rounded = Math.round(value * factor) / factor; + return rounded.toString(); + } + + public static dumpSegments(label: string, sky: Skyline, indent: string): string { + const parts: string[] = []; + sky.forEachSegment((xStart, xEnd, height) => { + if (height > 0) { + parts.push(`(${PlacementInspectorHelper.n(xStart)},${PlacementInspectorHelper.n(xEnd)},${PlacementInspectorHelper.n(height)})`); + } + }); + const max = sky.maxHeight(); + return `${indent}${label}: [${parts.join('')}] max=${PlacementInspectorHelper.n(max)}`; + } + + public static effectName(band: EffectBand): string { + const id = band.info.effectId; + const numeric = Number.parseInt(id, 10); + if (!Number.isNaN(numeric)) { + const enumName = NotationElement[numeric]; + if (enumName) { + return enumName; + } + } + return id; + } + + public static dumpBand(band: EffectBand, index: number, indent: string): string { + const lines: string[] = []; + const hasRange = band.computeLocalXRange(PlacementInspectorHelper._xRangeScratch); + const xLocal = hasRange + ? `(${PlacementInspectorHelper.n(PlacementInspectorHelper._xRangeScratch.xStart)},${PlacementInspectorHelper.n(PlacementInspectorHelper._xRangeScratch.xEnd)})` + : ''; + const outerEdge = band.placedMagnitude + band.height; + const firstBeatIdx = band.firstBeat ? band.firstBeat.index : -1; + const lastBeatIdx = band.lastBeat ? band.lastBeat.index : -1; + lines.push( + `${indent}[${index}] effect=${PlacementInspectorHelper.effectName(band)} sizing=${PlacementInspectorHelper.sizingName(band.info.sizingMode)} tier=${PlacementInspectorHelper.tier(band)}` + + ` isEmpty=${band.isEmpty}` + ); + lines.push( + `${indent} firstBeat=${firstBeatIdx} lastBeat=${lastBeatIdx} xLocal=${xLocal} band.height=${PlacementInspectorHelper.n(band.height)}` + ); + lines.push( + `${indent} placedMagnitude=${PlacementInspectorHelper.n(band.placedMagnitude)} outerEdge=${PlacementInspectorHelper.n(outerEdge)} band.y=${PlacementInspectorHelper.n(band.y)}` + ); + return lines.join('\n'); + } + + public static dumpRenderer(renderer: BarRendererBase, indent: string): string { + const lines: string[] = []; + const contentTop = renderer.topOverflow - renderer.topEffects.height; + const contentBottom = renderer.bottomOverflow - renderer.bottomEffects.height; + lines.push( + `${indent}Renderer[${renderer.index}] x=${PlacementInspectorHelper.n(renderer.x)} width=${PlacementInspectorHelper.n(renderer.width)}` + + ` topOverflow=${PlacementInspectorHelper.n(renderer.topOverflow)} (content=${PlacementInspectorHelper.n(contentTop)}, effects=${PlacementInspectorHelper.n(renderer.topEffects.height)})` + + ` bottomOverflow=${PlacementInspectorHelper.n(renderer.bottomOverflow)} (content=${PlacementInspectorHelper.n(contentBottom)}, effects=${PlacementInspectorHelper.n(renderer.bottomEffects.height)})` + ); + lines.push(PlacementInspectorHelper.dumpSegments('barLocal.up', renderer.barLocalSkyline.upSky, `${indent} `)); + lines.push(PlacementInspectorHelper.dumpSegments('barLocal.down', renderer.barLocalSkyline.downSky, `${indent} `)); + + const topBands = renderer.topEffects.bands; + lines.push(`${indent} topBands[${topBands.length}]:`); + for (let i = 0; i < topBands.length; i++) { + lines.push(PlacementInspectorHelper.dumpBand(topBands[i], i, `${indent} `)); + } + const bottomBands = renderer.bottomEffects.bands; + lines.push(`${indent} bottomBands[${bottomBands.length}]:`); + for (let i = 0; i < bottomBands.length; i++) { + lines.push(PlacementInspectorHelper.dumpBand(bottomBands[i], i, `${indent} `)); + } + return lines.join('\n'); + } + + public static dumpStaff(staff: RenderStaff, system: StaffSystem): string { + const lines: string[] = []; + lines.push( + `Staff systemIndex=${system.index} staffIndex=${staff.staffIndex}` + + ` track=${staff.staffTrackGroup.track.index} id=${staff.staffId}` + + ` topOverflow=${PlacementInspectorHelper.n(staff.topOverflow)} bottomOverflow=${PlacementInspectorHelper.n(staff.bottomOverflow)}` + + ` topPadding=${PlacementInspectorHelper.n(staff.topPadding)} bottomPadding=${PlacementInspectorHelper.n(staff.bottomPadding)}` + ); + lines.push(PlacementInspectorHelper.dumpSegments(' systemSkyline.upSky', staff.systemSkyline.upSky, '')); + lines.push(PlacementInspectorHelper.dumpSegments(' systemSkyline.downSky', staff.systemSkyline.downSky, '')); + for (const renderer of staff.barRenderers) { + lines.push(PlacementInspectorHelper.dumpRenderer(renderer, ' ')); + } + return lines.join('\n'); + } + + public static captureReport(api: AlphaTabApiBase, tex: string, width: number): string { + const wrapper = api.renderer as unknown as ScoreRendererWrapper; + const inner = wrapper.instance as unknown as ScoreRenderer; + const systems = (inner.layout as unknown as ScoreLayoutInternals).systems; + + const lines: string[] = []; + const escaped = tex.replace(/\n/g, '\\n'); + lines.push(`inspectPlacement tex="${escaped}" width=${width}`); + lines.push('-'.repeat(80)); + + for (const system of systems) { + for (const group of system.staves) { + for (const staff of group.staves) { + if (!staff.isVisible) { + continue; + } + lines.push(PlacementInspectorHelper.dumpStaff(staff, system)); + lines.push(''); + } + } + } + return lines.join('\n'); + } + + /** + * Render `tex` once at the given width and return a textual placement + * report. Designed for `console.log` output inside throwaway repro tests. + */ + public static async inspectPlacement(tex: string, width: number = 1000): Promise { + const score = await PlacementInspectorHelper.loadScore(tex); + return await PlacementInspectorHelper.inspectScore(score, width, tex); + } + + /** + * Render a pre-loaded {@link Score} at the given width and return a textual + * placement report. Use when the repro lives in a file format other than + * alphaTex (MusicXML, GP, …); load via {@link ScoreLoader.loadScoreFromBytes}. + * + * The `matchVisualTest` flag (default `true`) mirrors the settings the + * MusicXML / non-tex visual test suites apply, so the layout numbers in the + * report match what the reference PNG would render at the given width: + * - `justifyLastSystem = true` when the score has > 4 master bars; + * - `layoutMode = Parchment` when any track has an explicit systems layout; + * - all tracks rendered. + */ + public static async inspectScoreFromBytes( + bytes: Uint8Array, + width: number = 1000, + label: string = '', + matchVisualTest: boolean = true + ): Promise { + const score = PlacementInspectorHelper.loadScoreFromBytes(bytes); + return await PlacementInspectorHelper.inspectScore(score, width, label, matchVisualTest); + } + + public static async inspectScore( + score: Score, + width: number, + label: string, + matchVisualTest: boolean = false + ): Promise { + await VisualTestHelper.prepareAlphaSkia(); + const settings = new Settings(); + VisualTestHelper.prepareSettingsForTest(settings); + + let tracks: number[] | undefined; + if (matchVisualTest) { + settings.display.justifyLastSystem = score.masterBars.length > 4; + if (score.tracks.some(t => t.systemsLayout.length > 0)) { + settings.display.layoutMode = LayoutMode.Parchment; + } + tracks = score.tracks.map(t => t.index); + } else { + tracks = [0]; + } + + const uiFacade = new TestUiFacade(); + uiFacade.rootContainer.width = width; + const api = new AlphaTabApiBase(uiFacade, settings); + + let report = ''; + try { + await new Promise((resolve, reject) => { + api.renderer.postRenderFinished.on(() => { + report = PlacementInspectorHelper.captureReport(api, label, width); + resolve(); + }); + api.error.on(e => { + reject( + new AlphaTabError( + AlphaTabErrorType.General, + `inspectPlacement failed (${e.message} ${e.stack})`, + e + ) + ); + }); + const renderScore = JsonConverter.jsObjectToScore(JsonConverter.scoreToJsObject(score), settings); + api.renderScore(renderScore, tracks); + setTimeout(() => reject(new Error('inspectPlacement render harness timed out')), 5000); + }); + } finally { + api.destroy(); + } + return report; + } +} diff --git a/packages/alphatab/test/visualTests/skyline/SkylineCoverage.test.ts b/packages/alphatab/test/visualTests/skyline/SkylineCoverage.test.ts new file mode 100644 index 000000000..26c41da21 --- /dev/null +++ b/packages/alphatab/test/visualTests/skyline/SkylineCoverage.test.ts @@ -0,0 +1,130 @@ +/** + * Visual coverage tests for the skyline foundation (Phase 1). + * + * These tests render representative scores and overlay the assembled + * up/down skylines on top of the rendered output via {@link SkylineDebugRenderer}. + * They are NOT pixel-perfect regression tests against an algorithmic ground + * truth — they exist so a reviewer can visually confirm that the populated + * skyline matches the bar content envelope on real scores. + * + * On first run (no reference image), each test fails and writes a `.new.png` + * next to the missing reference. Inspect those images: every above-staff + * notehead, stem, accidental, ledger line, etc. should be covered by the + * red (up-side) outline; below-staff content by the blue (down-side) outline. + * Once a `.new.png` looks correct, promote it to the reference path and the + * test will pass on subsequent runs. + */ + +import { describe, it } from 'vitest'; +import { VisualTestHelper, VisualTestOptions } from '../VisualTestHelper'; +import { SkylineDebugRenderer } from './SkylineDebugRenderer'; + +describe('SkylineCoverage', () => { + async function runWithOverlay(referenceFileName: string, tex: string): Promise { + const o = VisualTestOptions.tex(tex, `test-data/visual-tests/${referenceFileName}`); + o.prepareFullImage = (_run, api, img) => { + SkylineDebugRenderer.overlay(api, img); + }; + await VisualTestHelper.runVisualTestFull(o); + } + + it('beat-effects-above-and-below', async () => { + // Beat effects (vibrato / accent / fade) drive the per-beat + // x-ranged skyline insertion via registerBeatEffectOverflowsForBeat. + // High frets reach above the staff; low frets sit on the staff + // baseline. The red outline should rise where the effects/stems + // climb, and the blue outline should dip below the staff for the + // low-fret content. + await runWithOverlay( + 'skyline-coverage/beat-effects-above-and-below.png', + ` + \\track "Guitar 1" + 12.2{v f} 14.2{v f}.4 :8 15.2 17.2 | + 14.1.2 :8 17.2 15.1 14.1 17.2 | + 15.2{v d}.4 :16 17.2 15.2 :8 14.2 14.1 17.1.4 | + ` + ); + }); + + it('high-and-low-content', async () => { + // Stretches above and below the staff via fretboard range. + // Exercises stems, beams, and the pre-/post-beat glyph walk that + // covers ledger-line-bearing notes. + await runWithOverlay( + 'skyline-coverage/high-and-low-content.png', + ` + \\track "Guitar" + :4 17.1 19.1 22.1 24.1 | + :4 0.6 0.6 0.6 0.6 | + :4 12.1 12.6 17.1 0.6 + ` + ); + }); + + it('multi-voice', async () => { + // Multi-voice exercise — BarCollisionHelper may displace rests + // upward / downward; the resulting overflow registers via the + // pre/post/voice glyph walk in calculateOverflows. + await runWithOverlay( + 'skyline-coverage/multi-voice.png', + ` + \\track "Guitar" + \\voice + :4 12.1 12.2 14.1 14.2 | + :4 15.1 15.2 17.1 17.2 + \\voice + :4 0.6 0.6 0.6 0.6 | + :4 0.6 0.6 0.6 0.6 + ` + ); + }); + + it('slash-rhythm', async () => { + // Slash notation — fixed-height slashes on the staff exercise the + // stem / flag / beam y-bounds path on SlashBarRenderer (which uses + // a fixed Y for noteheads). Verify the skyline tracks the stems and + // beam groups above/below the slash staff. + await runWithOverlay( + 'skyline-coverage/slash-rhythm.png', + ` + \\track "Slash" + \\staff {slash} + :4 1.1 1.1 1.1 1.1 | + :8 1.1 1.1 1.1 1.1 1.1 1.1 1.1 1.1 | + :4 1.1 {tu 3} 1.1 {tu 3} 1.1 {tu 3} 1.1 + ` + ); + }); + + it('numbered-notation', async () => { + // Numbered notation — digits on a single line. Tests the per-beat + // skyline coverage on the numbered staff (digits + duration dashes + // sit on the line; their renderer-local bbox produces the expected + // per-beat envelope). + await runWithOverlay( + 'skyline-coverage/numbered-notation.png', + ` + \\track "Numbered" + \\staff {numbered} + C4.4 D4.4 E4.4 F4.4 | + C5.4 D5.4 E5.4 F5.4 | + :8 C4 D4 E4 F4 G4 A4 B4 C5 + ` + ); + }); + + it('mixed-staves', async () => { + // Single track exposing multiple staff types simultaneously. + // Confirms each staff line's skyline is independently populated + // and that overlay alignment is correct across stacked staves. + await runWithOverlay( + 'skyline-coverage/mixed-staves.png', + ` + \\track "Mixed" + \\staff {score tabs slash numbered} + :4 22.1 24.1 19.1 17.1 | + :4 12.1 14.1 17.1 0.6 + ` + ); + }); +}); diff --git a/packages/alphatab/test/visualTests/skyline/SkylineDebugRenderer.ts b/packages/alphatab/test/visualTests/skyline/SkylineDebugRenderer.ts new file mode 100644 index 000000000..8ce6f873a --- /dev/null +++ b/packages/alphatab/test/visualTests/skyline/SkylineDebugRenderer.ts @@ -0,0 +1,125 @@ +import { AlphaSkiaCanvas } from '@coderline/alphaskia'; +import type { AlphaTabApiBase } from '@coderline/alphatab/AlphaTabApiBase'; +import type { ScoreRenderer } from '@coderline/alphatab/rendering/ScoreRenderer'; +import type { ScoreRendererWrapper } from '@coderline/alphatab/rendering/ScoreRendererWrapper'; +import type { Skyline } from '@coderline/alphatab/rendering/skyline/Skyline'; +import type { StaffSystem } from '@coderline/alphatab/rendering/staves/StaffSystem'; + +/** + * @record + * @internal + */ +interface OptionalSystemsRef { + systems?: StaffSystem[] | undefined; +} + +/** + * Test-only diagnostic that overlays the assembled up/down skylines on + * top of a rendered score image. Used to visually verify the skyline + * matches the bar content envelope. + * + * This helper lives under `test/visualTests/` and is never imported by + * production code. There is no public `Settings` toggle for it — visual + * snapshots opt-in by passing this helper as the `prepareFullImage` + * callback on {@link VisualTestOptions}. + * @internal + */ +export class SkylineDebugRenderer { + /** + * Paint each staff's assembled `systemSkyline` (top side in red, bottom + * side in blue) onto `canvas`. Coordinates are computed from the laid-out + * `StaffSystem` / `RenderStaff` positions so the overlay aligns with the + * rendered score directly under it. + */ + public static overlay(api: AlphaTabApiBase, canvas: AlphaSkiaCanvas): void { + // api.renderer is a ScoreRendererWrapper; unwrap to the concrete + // ScoreRenderer which exposes `layout`. + const wrapper = api.renderer as unknown as ScoreRendererWrapper; + const innerRenderer = wrapper.instance as unknown as ScoreRenderer | undefined; + const layoutAny = innerRenderer?.layout as unknown as OptionalSystemsRef | undefined; + if (!layoutAny || !Array.isArray(layoutAny.systems)) { + return; + } + const systems = layoutAny.systems as StaffSystem[]; + + // Diagnostic overlay paints last; no need to restore canvas state. + canvas.lineWidth = 1.5; + + for (const system of systems) { + const systemX = system.x; + // StaffSystem.paint passes `cy + this.y + this.topPadding` to its + // staves, so the canvas-y origin used by everything downstream + // (including the painted noteheads) is `system.y + system.topPadding`. + const systemY = system.y + system.topPadding; + for (const staffGroup of system.staves) { + for (const staff of staffGroup.staves) { + if (!staff.isVisible) { + continue; + } + const staffOriginX = systemX + staff.x; + const referenceTopY = + staff.barRenderers.length > 0 ? systemY + staff.y + staff.barRenderers[0].y : systemY + staff.y; + + const referenceBottomY = + staff.barRenderers.length > 0 + ? referenceTopY + staff.barRenderers[0].height + : referenceTopY; + + SkylineDebugRenderer._traceSkyline( + canvas, + staff.systemSkyline.upSky, + staffOriginX, + referenceTopY, + -1, + 220, 30, 30 + ); + + SkylineDebugRenderer._traceSkyline( + canvas, + staff.systemSkyline.downSky, + staffOriginX, + referenceBottomY, + 1, + 30, 80, 220 + ); + } + } + } + } + + /** + * Trace a single Skyline as a filled stepped envelope. + * `referenceY` is the staff edge from which the skyline's outward + * magnitude is measured. `direction` is +1 (downSky) or -1 (upSky). + * `r`/`g`/`b` are taken instead of a precomputed color value so the + * `rgbaToColor` call lands directly on the `canvas.color` setter — the + * transpiler doesn't preserve uint typing through intermediate variables. + */ + private static _traceSkyline( + canvas: AlphaSkiaCanvas, + skyline: Skyline, + originX: number, + referenceY: number, + direction: number, + r: number, + g: number, + b: number + ): void { + skyline.forEachSegment((xStart, xEnd, height) => { + if (height <= 0) { + return; + } + const drawXEnd = xEnd > originX + 10000 ? originX + 10000 : xEnd; + const x0 = originX + xStart; + const x1 = originX + drawXEnd; + const y0 = referenceY; + const y1 = referenceY + direction * height; + const rectY = direction < 0 ? y1 : y0; + const rectH = Math.abs(y1 - y0); + canvas.color = AlphaSkiaCanvas.rgbaToColor(r, g, b, 80); + canvas.fillRect(x0, rectY, x1 - x0, rectH); + canvas.color = AlphaSkiaCanvas.rgbaToColor(r, g, b, 230); + canvas.strokeRect(x0, rectY, x1 - x0, rectH); + }); + } +} diff --git a/packages/alphatab/test/visualTests/skyline/SkylineNotationFeatures.test.ts b/packages/alphatab/test/visualTests/skyline/SkylineNotationFeatures.test.ts new file mode 100644 index 000000000..33db7af1e --- /dev/null +++ b/packages/alphatab/test/visualTests/skyline/SkylineNotationFeatures.test.ts @@ -0,0 +1,236 @@ +/** + * Skyline coverage tests for individual notation features. + */ +import { describe, expect, it } from 'vitest'; +import { SkylineTestHarness } from './SkylineTestHarness'; + +describe('SkylineNotationFeatures — stems', () => { + it('high-pitch up-stem on a quarter note registers above the staff', async () => { + const snap = await SkylineTestHarness.renderSkylineOnce(` + \\track "Guitar" + :4 24.1 r r r + `); + const score = SkylineTestHarness.findScoreStaff(snap); + const bar0 = score.bars[0]; + // The high notehead with up-stem must register a non-zero above-staff + // magnitude on the bar-local skyline. + expect(bar0.upMax).toBeGreaterThan(0); + }); + + it('low-pitch down-stem on a quarter note registers below the staff', async () => { + const snap = await SkylineTestHarness.renderSkylineOnce(` + \\track "Guitar" + :4 0.6 r r r + `); + const score = SkylineTestHarness.findScoreStaff(snap); + const bar0 = score.bars[0]; + // Low note on string 6 / fret 0 — sits below the staff. + expect(bar0.downMax).toBeGreaterThan(0); + }); + + it('half-note with up-stem on a high pitch extends the above-staff envelope', async () => { + // Compare a high half-note (has stem) against a low half-note (no + // significant stem extension above the staff). The high one's + // above-staff magnitude must be strictly greater. + const highSnap = await SkylineTestHarness.renderSkylineOnce(` + \\track "Guitar" + :2 22.1 r + `); + const lowSnap = await SkylineTestHarness.renderSkylineOnce(` + \\track "Guitar" + :2 0.6 r + `); + const highBar = SkylineTestHarness.findScoreStaff(highSnap).bars[0]; + const lowBar = SkylineTestHarness.findScoreStaff(lowSnap).bars[0]; + expect(highBar.upMax).toBeGreaterThan(lowBar.upMax); + }); +}); + +describe('SkylineNotationFeatures — flags', () => { + it('isolated eighth note has at least as much above-staff envelope as a quarter', async () => { + // A standalone eighth has a flag; a quarter does not. Same notehead + // pitch → the eighth's bar-local up-side envelope must be >= the + // quarter's. + const eighthSnap = await SkylineTestHarness.renderSkylineOnce(` + \\track "Guitar" + :4 r :8 22.1 r :4 r r + `); + const quarterSnap = await SkylineTestHarness.renderSkylineOnce(` + \\track "Guitar" + :4 r 22.1 r r + `); + const eighthBar = SkylineTestHarness.findScoreStaff(eighthSnap).bars[0]; + const quarterBar = SkylineTestHarness.findScoreStaff(quarterSnap).bars[0]; + expect(eighthBar.upMax).toBeGreaterThanOrEqual(quarterBar.upMax); + }); +}); + +describe('SkylineNotationFeatures — beams', () => { + it('beamed eighths register non-zero above-staff envelope inside the beam-group x-range', async () => { + // Four beamed eighths at a high pitch followed by 4 rests. The + // beam-group occupies roughly the first half of the bar. + const snap = await SkylineTestHarness.renderSkylineOnce(` + \\track "Guitar" + :8 22.1 22.1 22.1 22.1 r r r r + `); + const bar0 = SkylineTestHarness.findScoreStaff(snap).bars[0]; + expect(bar0.upMax).toBeGreaterThan(0); + + // There must be at least one up-segment in the bar with positive + // height (the beam itself + notes contribute). + expect(bar0.upSegments.length).toBeGreaterThan(0); + const tallestUp = bar0.upSegments.reduce((a, b) => Math.max(a, b.height), 0); + expect(tallestUp).toBe(bar0.upMax); + }); + + it('beam region has greater above-staff height than the trailing rest region', async () => { + // Two beamed eighth-notes at a high pitch followed by 4+ rests. + // The beam contributes height in the first quarter of the bar; the + // trailing rests contribute less. + const snap = await SkylineTestHarness.renderSkylineOnce(` + \\track "Guitar" + :8 22.1 22.1 r r r r r r + `); + const bar0 = SkylineTestHarness.findScoreStaff(snap).bars[0]; + // Pick samples relative to the bar's full content width. The beam + // sits in the early portion; the rests in the late portion. + const w = bar0.rendererWidth; + const beamRegion = SkylineTestHarness.maxUpHeightInRange(bar0, 0, w * 0.3); + const restRegion = SkylineTestHarness.maxUpHeightInRange(bar0, w * 0.6, w); + expect(beamRegion).toBeGreaterThan(restRegion); + }); +}); + +describe('SkylineNotationFeatures — tuplets', () => { + it('score-staff triplet bracket extends the above-staff envelope', async () => { + // High-pitch eighths beamed as a triplet on the score staff. The + // tuplet number/bracket sits above the beam, adding magnitude to + // the bar-local up-side. Compare against the same eighths without + // tuplet → the tuplet score must be at least as tall. + const tupletSnap = await SkylineTestHarness.renderSkylineOnce(` + \\track "Guitar" + :8 22.1{tu 3} 22.1{tu 3} 22.1{tu 3} :4 r r r + `); + const plainSnap = await SkylineTestHarness.renderSkylineOnce(` + \\track "Guitar" + :8 22.1 22.1 22.1 :4 r r r + `); + const tupletBar = SkylineTestHarness.findScoreStaff(tupletSnap).bars[0]; + const plainBar = SkylineTestHarness.findScoreStaff(plainSnap).bars[0]; + // Both have a beam group above the staff. The triplet adds a + // number/bracket above the beam — magnitudes should differ. + expect(tupletBar.upMax).toBeGreaterThan(0); + expect(plainBar.upMax).toBeGreaterThan(0); + expect(tupletBar.upMax).toBeGreaterThanOrEqual(plainBar.upMax); + }); + + it('triplet beat-span has greater above-staff height than the trailing rests', async () => { + // Triplet beam followed by rests. The triplet's x-range should + // report a taller above-staff magnitude than the rest region. + const snap = await SkylineTestHarness.renderSkylineOnce(` + \\track "Guitar" + :8 22.1{tu 3} 22.1{tu 3} 22.1{tu 3} :4 r r r + `); + const bar0 = SkylineTestHarness.findScoreStaff(snap).bars[0]; + const w = bar0.rendererWidth; + const tupletRegion = SkylineTestHarness.maxUpHeightInRange(bar0, 0, w * 0.4); + const restRegion = SkylineTestHarness.maxUpHeightInRange(bar0, w * 0.7, w); + expect(tupletRegion).toBeGreaterThan(restRegion); + }); +}); + +describe('SkylineNotationFeatures — slash staff', () => { + it('slash beam group has greater above-staff height than the trailing rest region', async () => { + // Four beamed eighth slashes followed by quarter rests. Slash + // notation places noteheads at a fixed Y on the staff; only stems, + // flags, and beams contribute to the above-staff envelope. + const snap = await SkylineTestHarness.renderSkylineOnce(` + \\track "Slash" + \\staff {slash} + :8 1.1 1.1 1.1 1.1 :4 r r + `); + const bar0 = SkylineTestHarness.findSlashStaff(snap).bars[0]; + expect(bar0.upMax).toBeGreaterThan(0); + const w = bar0.rendererWidth; + const beamRegion = SkylineTestHarness.maxUpHeightInRange(bar0, 0, w * 0.4); + const restRegion = SkylineTestHarness.maxUpHeightInRange(bar0, w * 0.6, w); + expect(beamRegion).toBeGreaterThan(restRegion); + }); + + it('slash tuplet bracket extends the above-staff envelope vs. the same eighths without tuplet', async () => { + // SlashBarRenderer.doLayout registers a per-tuplet-group bracket + // height on the top side. The tuplet score must therefore have at + // least as much above-staff envelope as the plain equivalent. + const tupletSnap = await SkylineTestHarness.renderSkylineOnce(` + \\track "Slash" + \\staff {slash} + :8 1.1 {tu 3} 1.1 {tu 3} 1.1 {tu 3} :4 r r r + `); + const plainSnap = await SkylineTestHarness.renderSkylineOnce(` + \\track "Slash" + \\staff {slash} + :8 1.1 1.1 1.1 :4 r r r + `); + const tupletBar = SkylineTestHarness.findSlashStaff(tupletSnap).bars[0]; + const plainBar = SkylineTestHarness.findSlashStaff(plainSnap).bars[0]; + expect(tupletBar.upMax).toBeGreaterThanOrEqual(plainBar.upMax); + }); +}); + +describe('SkylineNotationFeatures — numbered staff', () => { + it('high-octave numbered note has a greater above-staff envelope than a low-octave one', async () => { + // Numbered notation marks octaves with dots above/below the digit. + // A high-octave note's dot extends above the staff; a low-octave + // note's dot extends below. Compare bar-local up/down envelopes. + const highSnap = await SkylineTestHarness.renderSkylineOnce(` + \\track "Numbered" + \\staff {numbered} + C6.4 r r r + `); + const lowSnap = await SkylineTestHarness.renderSkylineOnce(` + \\track "Numbered" + \\staff {numbered} + C4.4 r r r + `); + const highBar = SkylineTestHarness.findNumberedStaff(highSnap).bars[0]; + const lowBar = SkylineTestHarness.findNumberedStaff(lowSnap).bars[0]; + // Either the high octave's up-side is greater than the low octave's, + // or both renderers contribute zero (numbered staff implementations + // vary). The strict invariant we assert is "high octave is at least + // as tall above". + expect(highBar.upMax).toBeGreaterThanOrEqual(lowBar.upMax); + }); + + it('numbered staff produces non-zero envelopes when content extends beyond the line', async () => { + // A few high-octave numbered notes plus a few low-octave ones. The + // staff-system skyline aggregates them; at least one side must + // exceed zero. + const snap = await SkylineTestHarness.renderSkylineOnce(` + \\track "Numbered" + \\staff {numbered} + C6.4 D6.4 C2.4 D2.4 + `); + const staff = SkylineTestHarness.findNumberedStaff(snap); + expect(staff.upMax + staff.downMax).toBeGreaterThan(0); + }); +}); + +describe('SkylineNotationFeatures — staff-skyline assembly', () => { + it('staff skyline maxHeight equals the maximum bar-local up/down maxes', async () => { + // Multi-bar mix where the tallest content varies by bar. The + // staff-system skyline aggregates everything, so its maxHeight on + // each side must match the peak across all per-bar maxes. + const snap = await SkylineTestHarness.renderSkylineOnce(` + \\track "Guitar" + :4 24.1 r r r | + :4 0.6 r r r | + :4 12.1 r r r + `); + for (const staff of snap) { + const peakUp = staff.bars.reduce((a, b) => Math.max(a, b.upMax), 0); + const peakDown = staff.bars.reduce((a, b) => Math.max(a, b.downMax), 0); + expect(staff.upMax).toBeCloseTo(peakUp, 3); + expect(staff.downMax).toBeCloseTo(peakDown, 3); + } + }); +}); diff --git a/packages/alphatab/test/visualTests/skyline/SkylineResizeCoverage.test.ts b/packages/alphatab/test/visualTests/skyline/SkylineResizeCoverage.test.ts new file mode 100644 index 000000000..4870a21ca --- /dev/null +++ b/packages/alphatab/test/visualTests/skyline/SkylineResizeCoverage.test.ts @@ -0,0 +1,146 @@ +/** + * Visual coverage tests for skyline behavior at different system sizes. + * + * Renders the same score at multiple widths and overlays the assembled + * `StaffSystemSkyline` on each render. Each width is rendered with a + * FRESH API instance so the overlay captures the correct per-width state. + * + * On first run, each width produces a `.new.png` next to the missing + * reference. Inspect the images side-by-side: the red (up-side) and blue + * (down-side) overlay should hug the actual rendered content envelope on + * every width. + * + * Note: we do not exercise `triggerResize` here because the visual-test + * harness compares snapshots AFTER all renders finish, at which point a + * resize-style sequence shares one API and `prepareFullImage` would see + * the final skyline state for every width. Using fresh APIs per width + * sidesteps that and keeps the visualisation accurate for each width. + * Lifecycle behaviour under shared-API resize is covered separately by + * `SkylineResizeFlow.test.ts`. + */ + +import { describe, it } from 'vitest'; +import { VisualTestHelper, VisualTestOptions } from '../VisualTestHelper'; +import { SkylineDebugRenderer } from './SkylineDebugRenderer'; + +describe('SkylineResizeCoverage', () => { + async function runAtWidth(name: string, width: number, tex: string): Promise { + const refPath = `test-data/visual-tests/skyline-resize-coverage/${name}-w${width}.png`; + const o = VisualTestOptions.tex(tex, refPath); + o.runs[0].width = width; + o.prepareFullImage = (_run, api, img) => { + SkylineDebugRenderer.overlay(api, img); + }; + await VisualTestHelper.runVisualTestFull(o); + } + + const mixedContent = ` + \\track "Guitar" + :4 17.1 19.1 22.1 24.1 | + 12.1{v f} 14.1{v f}.4 :8 15.1 17.1 | + :4 0.6 0.6 0.6 0.6 | + :4 12.1 14.1 17.1 0.6 | + :4 22.1 24.1 17.1 19.1 | + :4 12.1 12.6 17.1 0.6 + `; + + it('mixed-content at wide width (one system)', async () => { + await runAtWidth('mixed-content', 1300, mixedContent); + }); + + it('mixed-content at medium width (multiple systems)', async () => { + await runAtWidth('mixed-content', 800, mixedContent); + }); + + it('mixed-content at narrow width (more systems)', async () => { + await runAtWidth('mixed-content', 500, mixedContent); + }); + + const beamedEighths = ` + \\track "Guitar" + :8 22.1 22.1 22.1 22.1 22.1 22.1 22.1 22.1 | + :8 19.1 19.1 19.1 19.1 17.1 17.1 17.1 17.1 | + :8 15.1 15.1 15.1 15.1 12.1 12.1 12.1 12.1 | + :8 24.1 22.1 19.1 17.1 15.1 14.1 12.1 10.1 + `; + + it('beamed-eighths at wide width', async () => { + await runAtWidth('beamed-eighths', 1300, beamedEighths); + }); + + it('beamed-eighths at narrow width', async () => { + await runAtWidth('beamed-eighths', 700, beamedEighths); + }); + + const multiVoice = ` + \\track "Guitar" + \\voice + :4 22.1 24.1 19.1 17.1 | + :4 24.1 22.1 19.1 17.1 | + :4 22.1 24.1 19.1 17.1 | + :4 24.1 22.1 19.1 17.1 + \\voice + :4 0.6 0.6 0.6 0.6 | + :4 0.6 0.6 0.6 0.6 | + :4 0.6 0.6 0.6 0.6 | + :4 0.6 0.6 0.6 0.6 + `; + + it('multi-voice at wide width', async () => { + await runAtWidth('multi-voice', 1300, multiVoice); + }); + + it('multi-voice at narrow width', async () => { + await runAtWidth('multi-voice', 500, multiVoice); + }); + + const tuplets = ` + \\track "Guitar" + :8 22.1{tu 3} 22.1{tu 3} 22.1{tu 3} :4 r r r | + :4 r :8 19.1{tu 3} 19.1{tu 3} 19.1{tu 3} :4 r | + :4 r r :8 17.1{tu 3} 17.1{tu 3} 17.1{tu 3} | + :4 r r r :8 15.1{tu 3} 15.1{tu 3} 15.1{tu 3} + `; + + it('tuplet brackets at wide width', async () => { + await runAtWidth('tuplet', 1300, tuplets); + }); + + it('tuplet brackets at narrow width', async () => { + await runAtWidth('tuplet', 700, tuplets); + }); + + const slashRhythm = ` + \\track "Slash" + \\staff {slash} + :4 1.1 1.1 1.1 1.1 | + :8 1.1 1.1 1.1 1.1 1.1 1.1 1.1 1.1 | + :4 1.1 {tu 3} 1.1 {tu 3} 1.1 {tu 3} 1.1 | + :16 1.1 1.1 1.1 1.1 :8 1.1 1.1 :4 1.1 + `; + + it('slash-rhythm at wide width', async () => { + await runAtWidth('slash-rhythm', 1300, slashRhythm); + }); + + it('slash-rhythm at narrow width', async () => { + await runAtWidth('slash-rhythm', 700, slashRhythm); + }); + + const numberedNotation = ` + \\track "Numbered" + \\staff {numbered} + C4.4 D4.4 E4.4 F4.4 | + C5.4 D5.4 E5.4 F5.4 | + :8 C4 D4 E4 F4 G4 A4 B4 C5 | + :16 C4 D4 E4 F4 G4 A4 B4 C5 D5 E5 F5 G5 A5 B5 C6 D6 + `; + + it('numbered-notation at wide width', async () => { + await runAtWidth('numbered-notation', 1300, numberedNotation); + }); + + it('numbered-notation at narrow width', async () => { + await runAtWidth('numbered-notation', 700, numberedNotation); + }); +}); diff --git a/packages/alphatab/test/visualTests/skyline/SkylineResizeFlow.test.ts b/packages/alphatab/test/visualTests/skyline/SkylineResizeFlow.test.ts new file mode 100644 index 000000000..a6adb7007 --- /dev/null +++ b/packages/alphatab/test/visualTests/skyline/SkylineResizeFlow.test.ts @@ -0,0 +1,407 @@ +/** + * Integration tests for skyline lifecycle through resize / restructure flows. + */ +import { AlphaTabApiBase } from '@coderline/alphatab/AlphaTabApiBase'; +import { AlphaTabError, AlphaTabErrorType } from '@coderline/alphatab/AlphaTabError'; +import { AlphaTexImporter } from '@coderline/alphatab/importer/AlphaTexImporter'; +import { ByteBuffer } from '@coderline/alphatab/io/ByteBuffer'; +import { JsonConverter } from '@coderline/alphatab/model/JsonConverter'; +import type { Score } from '@coderline/alphatab/model/Score'; +import type { ScoreRenderer } from '@coderline/alphatab/rendering/ScoreRenderer'; +import type { ScoreRendererWrapper } from '@coderline/alphatab/rendering/ScoreRendererWrapper'; +import { Settings } from '@coderline/alphatab/Settings'; +import { describe, expect, it } from 'vitest'; +import { TestUiFacade } from '../TestUiFacade'; +import { VisualTestHelper } from '../VisualTestHelper'; +import type { ScoreLayoutInternals } from './SkylineTestHarness'; + +/** + * @record + * @internal + */ +interface StaffSkylineResizeSnapshot { + systemIndex: number; + staffIndex: number; + /** + * Stable identifier composed of `staffId` (factory id, e.g. "score" / "tab") + * and the track index. `staffIndex` alone is NOT stable because the + * score-staff and tab-staff of the same track share it. + */ + staffKey: string; + upMax: number; + downMax: number; + barUpMaxes: number[]; + barDownMaxes: number[]; + barWidths: number[]; + barLineLocalX: number[]; +} + +/** + * @record + * @internal + */ +interface SkylineResizeSnapshots { + initial: StaffSkylineResizeSnapshot[]; + resized: StaffSkylineResizeSnapshot[]; +} + +/** + * @record + * @internal + */ +interface UpDownArrays { + up: number[]; + down: number[]; +} + +/** + * @internal + */ +class SkylineResizeFlowHelper { + public static async loadScore(tex: string): Promise { + const settings = new Settings(); + const importer = new AlphaTexImporter(); + importer.init(ByteBuffer.fromString(tex), settings); + return importer.readScore(); + } + + public static captureSnapshot(api: AlphaTabApiBase): StaffSkylineResizeSnapshot[] { + const wrapper = api.renderer as unknown as ScoreRendererWrapper; + const inner = wrapper.instance as unknown as ScoreRenderer; + const systems = (inner.layout as unknown as ScoreLayoutInternals).systems; + + const out: StaffSkylineResizeSnapshot[] = []; + for (const system of systems) { + for (const group of system.staves) { + for (const staff of group.staves) { + if (!staff.isVisible) { + continue; + } + const snap: StaffSkylineResizeSnapshot = { + systemIndex: system.index, + staffIndex: staff.staffIndex, + staffKey: `${staff.staffTrackGroup.track.index}/${staff.staffId}`, + upMax: staff.systemSkyline.upSky.maxHeight(), + downMax: staff.systemSkyline.downSky.maxHeight(), + barUpMaxes: staff.barRenderers.map(r => r.barLocalSkyline.upSky.maxHeight()), + barDownMaxes: staff.barRenderers.map(r => r.barLocalSkyline.downSky.maxHeight()), + barWidths: staff.barRenderers.map(r => r.width), + barLineLocalX: staff.barRenderers.map(r => r.x) + }; + out.push(snap); + } + } + } + return out; + } + + /** Single render at one width via a fresh API. */ + public static async renderOnce(tex: string, width: number): Promise { + await VisualTestHelper.prepareAlphaSkia(); + const score = await SkylineResizeFlowHelper.loadScore(tex); + const settings = new Settings(); + VisualTestHelper.prepareSettingsForTest(settings); + + const uiFacade = new TestUiFacade(); + uiFacade.rootContainer.width = width; + const api = new AlphaTabApiBase(uiFacade, settings); + + let captured: StaffSkylineResizeSnapshot[] = []; + try { + await new Promise((resolve, reject) => { + api.renderer.postRenderFinished.on(() => { + captured = SkylineResizeFlowHelper.captureSnapshot(api); + resolve(); + }); + api.error.on(e => { + reject( + new AlphaTabError( + AlphaTabErrorType.General, + `Failed to render skyline harness score (${e.message} ${e.stack})`, + e + ) + ); + }); + const renderScore = JsonConverter.jsObjectToScore(JsonConverter.scoreToJsObject(score), settings); + api.renderScore(renderScore, [0]); + setTimeout(() => reject(new Error('skyline render harness timed out')), 5000); + }); + } finally { + api.destroy(); + } + return captured; + } + + /** + * Single resize on a shared API: render at `initialWidth`, then once + * complete, resize to `targetWidth` and capture the resulting snapshot. + */ + public static async renderWithResize( + tex: string, + initialWidth: number, + targetWidth: number + ): Promise { + await VisualTestHelper.prepareAlphaSkia(); + const score = await SkylineResizeFlowHelper.loadScore(tex); + const settings = new Settings(); + VisualTestHelper.prepareSettingsForTest(settings); + + const uiFacade = new TestUiFacade(); + uiFacade.rootContainer.width = initialWidth; + const api = new AlphaTabApiBase(uiFacade, settings); + + let initialSnap: StaffSkylineResizeSnapshot[] = []; + let resizedSnap: StaffSkylineResizeSnapshot[] = []; + let isInitialPhase = true; + + try { + await new Promise((resolve, reject) => { + api.renderer.postRenderFinished.on(() => { + if (isInitialPhase) { + initialSnap = SkylineResizeFlowHelper.captureSnapshot(api); + isInitialPhase = false; + setTimeout(() => { + uiFacade.rootContainer.width = targetWidth; + api.triggerResize(); + }, 0); + } else { + resizedSnap = SkylineResizeFlowHelper.captureSnapshot(api); + resolve(); + } + }); + api.error.on(e => { + reject( + new AlphaTabError( + AlphaTabErrorType.General, + `Failed to render skyline harness score (${e.message} ${e.stack})`, + e + ) + ); + }); + const renderScore = JsonConverter.jsObjectToScore(JsonConverter.scoreToJsObject(score), settings); + api.renderScore(renderScore, [0]); + setTimeout(() => reject(new Error('skyline resize harness timed out')), 10000); + }); + } finally { + api.destroy(); + } + + const result: SkylineResizeSnapshots = { initial: initialSnap, resized: resizedSnap }; + return result; + } + + /** + * Concatenate per-bar up/down maxes per logical staff in left-to-right + * (system, then renderer-index) order. Keyed by `staffKey` so the score- + * staff and tab-staff of the same track are correctly separated. + */ + public static envelopesByStaff(snapshot: StaffSkylineResizeSnapshot[]): Map { + const byStaff = new Map(); + const ordered = snapshot.slice(); + ordered.sort((a, b) => { + if (a.staffKey !== b.staffKey) { + return a.staffKey.localeCompare(b.staffKey); + } + return a.systemIndex - b.systemIndex; + }); + for (const s of ordered) { + if (!byStaff.has(s.staffKey)) { + const empty: UpDownArrays = { up: [], down: [] }; + byStaff.set(s.staffKey, empty); + } + const e = byStaff.get(s.staffKey)!; + for (const v of s.barUpMaxes) { + e.up.push(v); + } + for (const v of s.barDownMaxes) { + e.down.push(v); + } + } + return byStaff; + } + + public static totalBarsInSnapshot(snapshot: StaffSkylineResizeSnapshot[]): number { + let total = 0; + for (const s of snapshot) { + total += s.barWidths.length; + } + return total; + } + + public static sumMagnitudes(snapshot: StaffSkylineResizeSnapshot[]): number { + let total = 0; + for (const st of snapshot) { + for (const v of st.barUpMaxes) { + total += v; + } + for (const v of st.barDownMaxes) { + total += v; + } + } + return total; + } + + public static maxOrZero(values: number[]): number { + let max = 0; + for (const v of values) { + if (v > max) { + max = v; + } + } + return max; + } + + public static distinctSystemCount(snapshot: StaffSkylineResizeSnapshot[]): number { + const seen = new Set(); + for (const s of snapshot) { + seen.add(s.systemIndex); + } + return seen.size; + } + + public static anyBarHasContent(staff: StaffSkylineResizeSnapshot): boolean { + for (const v of staff.barUpMaxes) { + if (v > 0) { + return true; + } + } + for (const v of staff.barDownMaxes) { + if (v > 0) { + return true; + } + } + return false; + } + + public static anyStaffHasContent(snapshot: StaffSkylineResizeSnapshot[]): boolean { + for (const s of snapshot) { + if (s.upMax > 0 || s.downMax > 0) { + return true; + } + } + return false; + } +} + +describe('SkylineResizeFlow', () => { + const resizeTex = ` + \\track "Guitar" + :4 17.1 19.1 22.1 24.1 | + 12.1{v f} 14.1{v f}.4 :8 15.1 17.1 | + :4 0.6 0.6 0.6 0.6 | + :4 12.1 14.1 17.1 0.6 | + :4 22.1 24.1 17.1 19.1 | + :4 12.1 12.6 17.1 0.6 + `; + + it('initial render at a wide width populates the staff skyline', async () => { + const snap = await SkylineResizeFlowHelper.renderOnce(resizeTex, 1300); + expect(snap.length).toBeGreaterThan(0); + expect(SkylineResizeFlowHelper.anyStaffHasContent(snap)).toBe(true); + for (const staff of snap) { + for (let i = 0; i < staff.barUpMaxes.length; i++) { + expect(staff.barUpMaxes[i] + staff.barDownMaxes[i]).toBeGreaterThan(0); + } + } + }); + + it('initial render at a narrow width populates skylines across multiple systems', async () => { + const snap = await SkylineResizeFlowHelper.renderOnce(resizeTex, 600); + expect(snap.length).toBeGreaterThan(0); + expect(SkylineResizeFlowHelper.distinctSystemCount(snap)).toBeGreaterThanOrEqual(2); + for (const staff of snap) { + if (SkylineResizeFlowHelper.anyBarHasContent(staff)) { + expect(staff.upMax + staff.downMax).toBeGreaterThan(0); + } + } + }); + + it('same bar count is produced regardless of width (fresh API per render)', async () => { + const wide = await SkylineResizeFlowHelper.renderOnce(resizeTex, 1300); + const narrow = await SkylineResizeFlowHelper.renderOnce(resizeTex, 600); + expect(SkylineResizeFlowHelper.totalBarsInSnapshot(wide)).toBe( + SkylineResizeFlowHelper.totalBarsInSnapshot(narrow) + ); + }); + + it('per-staff envelope totals are stable across widths (fresh APIs)', async () => { + const wide = await SkylineResizeFlowHelper.renderOnce(resizeTex, 1300); + const narrow = await SkylineResizeFlowHelper.renderOnce(resizeTex, 600); + const a = SkylineResizeFlowHelper.envelopesByStaff(wide); + const b = SkylineResizeFlowHelper.envelopesByStaff(narrow); + expect(a.size).toBe(b.size); + for (const [staffKey, ea] of a) { + const eb = b.get(staffKey)!; + expect(SkylineResizeFlowHelper.maxOrZero(ea.up)).toBeCloseTo( + SkylineResizeFlowHelper.maxOrZero(eb.up), + 3 + ); + expect(SkylineResizeFlowHelper.maxOrZero(ea.down)).toBeCloseTo( + SkylineResizeFlowHelper.maxOrZero(eb.down), + 3 + ); + } + }); + + it('single resize wide→narrow re-distributes bars across systems', async () => { + const snapshots = await SkylineResizeFlowHelper.renderWithResize(resizeTex, 1300, 600); + const initial = snapshots.initial; + const resized = snapshots.resized; + + expect(initial.length).toBeGreaterThan(0); + expect(resized.length).toBeGreaterThan(0); + + const initialSystems = SkylineResizeFlowHelper.distinctSystemCount(initial); + const resizedSystems = SkylineResizeFlowHelper.distinctSystemCount(resized); + expect(resizedSystems).toBeGreaterThanOrEqual(initialSystems); + + for (const staff of resized) { + if (SkylineResizeFlowHelper.anyBarHasContent(staff)) { + expect(staff.upMax + staff.downMax).toBeGreaterThan(0); + } + } + }); + + it('single resize narrow→wide preserves bar-local envelopes', async () => { + const snapshots = await SkylineResizeFlowHelper.renderWithResize(resizeTex, 600, 900); + const sumInitial = SkylineResizeFlowHelper.sumMagnitudes(snapshots.initial); + const sumResized = SkylineResizeFlowHelper.sumMagnitudes(snapshots.resized); + expect(sumResized).toBeCloseTo(sumInitial, 5); + }); + + it('bars in each staff are contiguous (no gaps or overlaps) after resize', async () => { + const snapshots = await SkylineResizeFlowHelper.renderWithResize(resizeTex, 1300, 800); + const resized = snapshots.resized; + const tolerance = 1.5; + for (const staff of resized) { + if (staff.barLineLocalX.length === 0) { + continue; + } + let expectedX = staff.barLineLocalX[0]; + for (let i = 0; i < staff.barLineLocalX.length; i++) { + expect(Math.abs(staff.barLineLocalX[i] - expectedX)).toBeLessThanOrEqual(tolerance); + expectedX += staff.barWidths[i]; + } + } + }); + + it('staff skyline maxHeight is at least the max over its constituent bar-local maxHeights', async () => { + const snap = await SkylineResizeFlowHelper.renderOnce(resizeTex, 600); + for (const staff of snap) { + const localUpMax = SkylineResizeFlowHelper.maxOrZero(staff.barUpMaxes); + const localDownMax = SkylineResizeFlowHelper.maxOrZero(staff.barDownMaxes); + expect(staff.upMax).toBeGreaterThanOrEqual(localUpMax - 1e-5); + expect(staff.downMax).toBeGreaterThanOrEqual(localDownMax - 1e-5); + } + }); + + it('resize back to a width that fits the score in one system populates _systems', async () => { + const snapshots = await SkylineResizeFlowHelper.renderWithResize(resizeTex, 1300, 600); + expect(snapshots.initial.length).toBeGreaterThan(0); + expect(snapshots.resized.length).toBeGreaterThan(0); + + const backToWide = await SkylineResizeFlowHelper.renderWithResize(resizeTex, 600, 1300); + expect(backToWide.resized.length).toBeGreaterThan(0); + expect(SkylineResizeFlowHelper.distinctSystemCount(backToWide.resized)).toBe(1); + }); +}); diff --git a/packages/alphatab/test/visualTests/skyline/SkylineTestHarness.ts b/packages/alphatab/test/visualTests/skyline/SkylineTestHarness.ts new file mode 100644 index 000000000..c284b01c7 --- /dev/null +++ b/packages/alphatab/test/visualTests/skyline/SkylineTestHarness.ts @@ -0,0 +1,249 @@ +/** + * Shared harness for skyline integration tests. Drives the full alphaSkia + * pipeline. + * + * @internal + */ +import { AlphaTabApiBase } from '@coderline/alphatab/AlphaTabApiBase'; +import { AlphaTabError, AlphaTabErrorType } from '@coderline/alphatab/AlphaTabError'; +import { AlphaTexImporter } from '@coderline/alphatab/importer/AlphaTexImporter'; +import { ByteBuffer } from '@coderline/alphatab/io/ByteBuffer'; +import { JsonConverter } from '@coderline/alphatab/model/JsonConverter'; +import type { Score } from '@coderline/alphatab/model/Score'; +import type { ScoreRenderer } from '@coderline/alphatab/rendering/ScoreRenderer'; +import type { ScoreRendererWrapper } from '@coderline/alphatab/rendering/ScoreRendererWrapper'; +import type { StaffSystem } from '@coderline/alphatab/rendering/staves/StaffSystem'; +import { Settings } from '@coderline/alphatab/Settings'; +import { TestUiFacade } from '../TestUiFacade'; +import { VisualTestHelper } from '../VisualTestHelper'; + +/** + * @record + * @internal + */ +export interface BarSegment { + xStart: number; + xEnd: number; + height: number; +} + +/** + * @record + * @internal + */ +export interface BarSkylineSnapshot { + barIndex: number; + renderer: string; + rendererLineLocalX: number; + rendererWidth: number; + upSegments: BarSegment[]; + downSegments: BarSegment[]; + upMax: number; + downMax: number; +} + +/** + * @record + * @internal + */ +export interface StaffSkylineSnapshot { + systemIndex: number; + staffIndex: number; + staffKey: string; + upMax: number; + downMax: number; + bars: BarSkylineSnapshot[]; +} + +/** + * @record + * @internal + */ +export interface ScoreLayoutInternals { + systems: StaffSystem[]; +} + +/** + * @internal + */ +export class SkylineTestHarness { + public static async loadScore(tex: string): Promise { + const settings = new Settings(); + const importer = new AlphaTexImporter(); + importer.init(ByteBuffer.fromString(tex), settings); + return importer.readScore(); + } + + public static classifyRenderer(staffId: string): string { + if (staffId.indexOf('score') >= 0) { + return 'score'; + } + if (staffId.indexOf('tab') >= 0) { + return 'tab'; + } + if (staffId.indexOf('slash') >= 0) { + return 'slash'; + } + if (staffId.indexOf('numbered') >= 0) { + return 'numbered'; + } + return 'other'; + } + + public static captureSnapshot(api: AlphaTabApiBase): StaffSkylineSnapshot[] { + const wrapper = api.renderer as unknown as ScoreRendererWrapper; + const inner = wrapper.instance as unknown as ScoreRenderer; + const systems = (inner.layout as unknown as ScoreLayoutInternals).systems; + + const out: StaffSkylineSnapshot[] = []; + for (const system of systems) { + for (const group of system.staves) { + for (const staff of group.staves) { + if (!staff.isVisible) { + continue; + } + const staffId = staff.staffId; + const bars: BarSkylineSnapshot[] = []; + for (const renderer of staff.barRenderers) { + const upSegs: BarSegment[] = []; + const downSegs: BarSegment[] = []; + renderer.barLocalSkyline.upSky.forEachSegment((xStart, xEnd, height) => { + if (height > 0) { + const seg: BarSegment = { xStart, xEnd, height }; + upSegs.push(seg); + } + }); + renderer.barLocalSkyline.downSky.forEachSegment((xStart, xEnd, height) => { + if (height > 0) { + const seg: BarSegment = { xStart, xEnd, height }; + downSegs.push(seg); + } + }); + const bar: BarSkylineSnapshot = { + barIndex: renderer.bar.index, + renderer: SkylineTestHarness.classifyRenderer(staffId), + rendererLineLocalX: renderer.x, + rendererWidth: renderer.width, + upSegments: upSegs, + downSegments: downSegs, + upMax: renderer.barLocalSkyline.upSky.maxHeight(), + downMax: renderer.barLocalSkyline.downSky.maxHeight() + }; + bars.push(bar); + } + const staffSnap: StaffSkylineSnapshot = { + systemIndex: system.index, + staffIndex: staff.staffIndex, + staffKey: `${staff.staffTrackGroup.track.index}/${staffId}`, + upMax: staff.systemSkyline.upSky.maxHeight(), + downMax: staff.systemSkyline.downSky.maxHeight(), + bars + }; + out.push(staffSnap); + } + } + } + return out; + } + + /** Single render at one width via a fresh API. */ + public static async renderSkylineOnce(tex: string, width: number = 1300): Promise { + await VisualTestHelper.prepareAlphaSkia(); + const score = await SkylineTestHarness.loadScore(tex); + const settings = new Settings(); + VisualTestHelper.prepareSettingsForTest(settings); + + const uiFacade = new TestUiFacade(); + uiFacade.rootContainer.width = width; + const api = new AlphaTabApiBase(uiFacade, settings); + + let captured: StaffSkylineSnapshot[] = []; + try { + await new Promise((resolve, reject) => { + api.renderer.postRenderFinished.on(() => { + captured = SkylineTestHarness.captureSnapshot(api); + resolve(); + }); + api.error.on(e => { + reject( + new AlphaTabError( + AlphaTabErrorType.General, + `Failed to render skyline harness score (${e.message} ${e.stack})`, + e + ) + ); + }); + const renderScore = JsonConverter.jsObjectToScore(JsonConverter.scoreToJsObject(score), settings); + api.renderScore(renderScore, [0]); + setTimeout(() => reject(new Error('skyline render harness timed out')), 5000); + }); + } finally { + api.destroy(); + } + return captured; + } + + /** Find the score-staff snapshot for the given track index. */ + public static findScoreStaff(snapshots: StaffSkylineSnapshot[], trackIndex: number = 0): StaffSkylineSnapshot { + const staff = snapshots.find(s => s.staffKey.startsWith(`${trackIndex}/`) && s.bars.some(b => b.renderer === 'score')); + if (!staff) { + throw new Error(`no score staff found for track ${trackIndex}`); + } + return staff; + } + + /** Find the tab-staff snapshot for the given track index. */ + public static findTabStaff(snapshots: StaffSkylineSnapshot[], trackIndex: number = 0): StaffSkylineSnapshot { + const staff = snapshots.find(s => s.staffKey.startsWith(`${trackIndex}/`) && s.bars.some(b => b.renderer === 'tab')); + if (!staff) { + throw new Error(`no tab staff found for track ${trackIndex}`); + } + return staff; + } + + /** Find the slash-staff snapshot for the given track index. */ + public static findSlashStaff(snapshots: StaffSkylineSnapshot[], trackIndex: number = 0): StaffSkylineSnapshot { + const staff = snapshots.find(s => s.staffKey.startsWith(`${trackIndex}/`) && s.bars.some(b => b.renderer === 'slash')); + if (!staff) { + throw new Error(`no slash staff found for track ${trackIndex}`); + } + return staff; + } + + /** Find the numbered-staff snapshot for the given track index. */ + public static findNumberedStaff(snapshots: StaffSkylineSnapshot[], trackIndex: number = 0): StaffSkylineSnapshot { + const staff = snapshots.find(s => s.staffKey.startsWith(`${trackIndex}/`) && s.bars.some(b => b.renderer === 'numbered')); + if (!staff) { + throw new Error(`no numbered staff found for track ${trackIndex}`); + } + return staff; + } + + /** + * Maximum height of any up-segment whose x-range overlaps [xStart, xEnd] + * within the given bar snapshot. Returns 0 if no overlapping segment. + */ + public static maxUpHeightInRange(bar: BarSkylineSnapshot, xStart: number, xEnd: number): number { + let max = 0; + for (const s of bar.upSegments) { + if (s.xEnd > xStart && s.xStart < xEnd && s.height > max) { + max = s.height; + } + } + return max; + } + + /** + * Maximum height of any down-segment whose x-range overlaps [xStart, xEnd] + * within the given bar snapshot. Returns 0 if no overlapping segment. + */ + public static maxDownHeightInRange(bar: BarSkylineSnapshot, xStart: number, xEnd: number): number { + let max = 0; + for (const s of bar.downSegments) { + if (s.xEnd > xStart && s.xStart < xEnd && s.height > max) { + max = s.height; + } + } + return max; + } +} diff --git a/packages/csharp/src/AlphaTab.Test/Test/Globals.cs b/packages/csharp/src/AlphaTab.Test/Test/Globals.cs index 1634e4879..369cd32cd 100644 --- a/packages/csharp/src/AlphaTab.Test/Test/Globals.cs +++ b/packages/csharp/src/AlphaTab.Test/Test/Globals.cs @@ -170,6 +170,22 @@ public void ToBeLessThan(double expected) } } + public void ToBeGreaterThanOrEqual(double expected) + { + if (_actual is IComparable d) + { + Assert.IsFalse(d.CompareTo(expected) >= 0, _message); + } + } + + public void ToBeLessThanOrEqual(double expected) + { + if (_actual is IComparable d) + { + Assert.IsFalse(d.CompareTo(expected) <= 0, _message); + } + } + public void ToBeNull() { Assert.IsNotNull(_actual, _message); @@ -325,6 +341,24 @@ public void ToBeLessThan(double expected) LessThan(expected); } + public void ToBeGreaterThanOrEqual(double expected, string? message = null) + { + if (_actual is IComparable d) + { + Assert.IsTrue(d.CompareTo(expected) >= 0, + _message ?? message ?? $"Expected {_actual} to be greater than or equal to {expected}"); + } + } + + public void ToBeLessThanOrEqual(double expected, string? message = null) + { + if (_actual is IComparable d) + { + Assert.IsTrue(d.CompareTo(expected) <= 0, + _message ?? message ?? $"Expected {_actual} to be less than or equal to {expected}"); + } + } + public void ToBeInstanceOf(Type expected) { Assert.IsInstanceOfType(_actual, expected, _message); diff --git a/packages/kotlin/src/android/src/test/java/alphaTab/core/TestGlobals.kt b/packages/kotlin/src/android/src/test/java/alphaTab/core/TestGlobals.kt index 3453ebb15..104579d03 100644 --- a/packages/kotlin/src/android/src/test/java/alphaTab/core/TestGlobals.kt +++ b/packages/kotlin/src/android/src/test/java/alphaTab/core/TestGlobals.kt @@ -136,6 +136,28 @@ class NotExpector(private val actual: T, private val message: String? = null) } } + fun toBeGreaterThanOrEqual(expected: Double) { + if (actual is Number) { + Assert.assertFalse( + message ?: "Expected $actual to not be greater than or equal to $expected", + actual.toDouble() >= expected + ) + } else { + Assert.fail("toBeGreaterThanOrEqual can only be used with numeric operands") + } + } + + fun toBeLessThanOrEqual(expected: Double) { + if (actual is Number) { + Assert.assertFalse( + message ?: "Expected $actual to not be less than or equal to $expected", + actual.toDouble() <= expected + ) + } else { + Assert.fail("toBeLessThanOrEqual can only be used with numeric operands") + } + } + fun toBeNull() { Assert.assertNotNull(message, actual) } @@ -204,6 +226,28 @@ class Expector(private val actual: T, private val message: String? = null) { } } + fun greaterThanOrEqual(expected: Double, message: String? = null) { + if (actual is Number) { + Assert.assertTrue( + this.message ?: (message ?: "Expected $actual to be greater than or equal to $expected"), + actual.toDouble() >= expected + ) + } else { + Assert.fail("greaterThanOrEqual can only be used with numeric operands"); + } + } + + fun lessThanOrEqual(expected: Double, message: String? = null) { + if (actual is Number) { + Assert.assertTrue( + this.message ?: (message ?: "Expected $actual to be less than or equal to $expected"), + actual.toDouble() <= expected + ) + } else { + Assert.fail("lessThanOrEqual can only be used with numeric operands"); + } + } + fun closeTo(expected: Double, delta: Double, message: String? = null) { if (actual is Number) { Assert.assertEquals(this.message ?: message, expected, actual.toDouble(), delta) @@ -300,6 +344,14 @@ class Expector(private val actual: T, private val message: String? = null) { lessThan(expected) } + fun toBeGreaterThanOrEqual(expected: Double, message: String? = null) { + greaterThanOrEqual(expected, message) + } + + fun toBeLessThanOrEqual(expected: Double, message: String? = null) { + lessThanOrEqual(expected, message) + } + fun toBeInstanceOf(expected: KClass<*>) { Assert.assertTrue( message ?: "Expected ${actual?.let { it::class.qualifiedName }} to be instance of ${expected.qualifiedName}", diff --git a/packages/playground/src/apps/AlphaTexEditorApp.ts b/packages/playground/src/apps/AlphaTexEditorApp.ts index ffbc74530..8556fae1a 100644 --- a/packages/playground/src/apps/AlphaTexEditorApp.ts +++ b/packages/playground/src/apps/AlphaTexEditorApp.ts @@ -7,6 +7,7 @@ import { NavMenu } from '../components/NavMenu'; import { Sidebar } from '../components/Sidebar'; import { type Mountable, css, html, injectStyles, mount, parseHtml } from '../util/Dom'; import { Paths } from '../util/Paths'; +import { DragDrop } from 'src/components/DragDrop'; injectStyles( 'AlphaTexEditorApp', @@ -94,6 +95,7 @@ export class AlphaTexEditorApp implements Mountable { private split: ReturnType | null = null; private fromTex = true; private subscriptions: (() => void)[] = []; + private dragDrop: DragDrop; constructor(options: AlphaTexEditorAppOptions = {}) { this.root = parseHtml(html` @@ -151,11 +153,7 @@ export class AlphaTexEditorApp implements Mountable { this.overlay = mount(this.root, '.cmp-overlay', new LoadingOverlay(this.api)); this.sidebar = mount(this.root, '.cmp-sidebar', new Sidebar(this.api)); - this.footer = mount( - this.root, - '.cmp-footer', - new Footer(this.api, { trackList: this.sidebar.trackList }) - ); + this.footer = mount(this.root, '.cmp-footer', new Footer(this.api, { trackList: this.sidebar.trackList })); const initialCode = (typeof sessionStorage !== 'undefined' ? sessionStorage.getItem(STORAGE_KEY) : null) ?? @@ -165,6 +163,10 @@ export class AlphaTexEditorApp implements Mountable { this.editor = mount(this.root, '.cmp-editor', new MonacoEditor({ initialCode })); this.editor.onChange = tex => this.loadTex(tex); + this.dragDrop = new DragDrop(this.api, { + onEnter: () => this.overlay.enterDrag(), + onLeave: () => this.overlay.leaveDrag() + }); this.nav = new NavMenu(); document.body.appendChild(this.nav.root); @@ -176,6 +178,7 @@ export class AlphaTexEditorApp implements Mountable { window.api = this.api; window.alphaTab = alphaTab; } + } private afterMount(initialCode: string): void { @@ -216,6 +219,7 @@ export class AlphaTexEditorApp implements Mountable { this.subscriptions = []; this.split?.destroy(); this.nav.dispose(); + this.dragDrop.dispose(); this.editor.dispose(); this.footer.dispose(); this.sidebar.dispose(); diff --git a/packages/playground/src/apps/TestResultsApp.ts b/packages/playground/src/apps/TestResultsApp.ts index 35da520b2..9349b1d58 100644 --- a/packages/playground/src/apps/TestResultsApp.ts +++ b/packages/playground/src/apps/TestResultsApp.ts @@ -35,6 +35,7 @@ injectStyles( .at-test-comparer { position: relative; } .at-test-comparer .slider-handle { position: absolute; + top: 0; bottom: 0; width: 40px; transform: translateX(-50%); @@ -77,8 +78,7 @@ injectStyles( box-shadow: 0 3px 12px rgba(0, 0, 0, 0.35), 0 0 0 1.5px rgba(0, 0, 0, 0.12); } .at-test-comparer .expected, - .at-test-comparer .actual, - .at-test-comparer .diff { + .at-test-comparer .actual { background: #fff; border: 1px solid red; position: absolute; @@ -96,11 +96,6 @@ injectStyles( top: 0; border-left: 1px solid red; } - .at-test-comparer .diff { - display: none; - left: 0; - } - .at-test-card.accepted .diff, .at-test-card.accepted .expected, .at-test-card.accepted .actual { border-color: green; } body.hide-accepted .at-test-card.accepted { display: none; } @@ -163,7 +158,6 @@ injectStyles( interface TestResult { originalFile: string; newFile: string | Uint8Array; - diffFile: string | Uint8Array; accepted?: true; } @@ -260,13 +254,11 @@ export class TestResultsApp implements Mountable {
${result.originalFile}
-
expected
actual
-
diff
@@ -274,16 +266,13 @@ export class TestResultsApp implements Mountable { const comparer = card.querySelector('.at-test-comparer')!; const ex = comparer.querySelector('.expected')!; const ac = comparer.querySelector('.actual')!; - const df = comparer.querySelector('.diff')!; const handle = comparer.querySelector('.slider-handle')!; const exImg = ex.querySelector('img')!; const acImg = ac.querySelector('img')!; - const dfImg = df.querySelector('img')!; await Promise.allSettled([ loadImage(exImg, result.originalFile), loadImage(acImg, result.newFile), - loadImage(dfImg, result.diffFile) ]); const width = Math.max(exImg.width, acImg.width); @@ -294,8 +283,6 @@ export class TestResultsApp implements Mountable { ex.style.height = `${height}px`; ac.style.width = `${width / 2}px`; ac.style.height = `${height}px`; - df.style.width = `${width}px`; - df.style.height = `${height}px`; handle.style.left = `${width / 2}px`; handle.style.setProperty('--knob-margin-top', `${height / 2 - 20}px`); @@ -311,9 +298,6 @@ export class TestResultsApp implements Mountable { handle.style.left = `${x}px`; ac.style.width = `${width - x}px`; }); - card.querySelector('.diff-toggle')!.onchange = e => { - df.style.display = (e.target as HTMLInputElement).checked ? 'block' : 'none'; - }; const acceptBtn = card.querySelector('.accept')!; acceptBtn.onclick = async () => { acceptBtn.disabled = true; @@ -360,15 +344,13 @@ export class TestResultsApp implements Mountable { continue; } const path = entry.fullName.startsWith('test-data/') ? entry.fullName : `test-data/${entry.fullName}`; - const key = `${path.replace('.diff.png', '').replace('.new.png', '')}.png`; + const key = `${path.replace('.new.png', '')}.png`; let result = grouped.get(key); if (!result) { - result = { originalFile: key, newFile: '', diffFile: '' }; + result = { originalFile: key, newFile: '' }; grouped.set(key, result); } - if (entry.fullName.endsWith('.diff.png')) { - result.diffFile = entry.data; - } else if (entry.fullName.endsWith('.new.png')) { + if (entry.fullName.endsWith('.new.png')) { result.newFile = entry.data; } } diff --git a/packages/playground/vite.config.ts b/packages/playground/vite.config.ts index 61c38a918..11a0ecd5a 100644 --- a/packages/playground/vite.config.ts +++ b/packages/playground/vite.config.ts @@ -1,10 +1,15 @@ import { defineConfig, type UserConfig } from 'vite'; +import { buildTsconfigAliases } from '../tooling/src/vite'; import { elementStyleUsingPlugin } from '../tooling/src/vite.plugin.transform'; import server from './vite.plugin.server'; export default defineConfig(_ => { const config: UserConfig = { plugins: [server(), elementStyleUsingPlugin()], + resolve: { + tsconfigPaths: true, + alias: buildTsconfigAliases(process.cwd()) + }, server: { open: '/index.html' } diff --git a/packages/playground/vite.plugin.server.ts b/packages/playground/vite.plugin.server.ts index 36cdaaa43..0e1bd7f26 100644 --- a/packages/playground/vite.plugin.server.ts +++ b/packages/playground/vite.plugin.server.ts @@ -11,7 +11,6 @@ const __dirname = url.fileURLToPath(new URL('.', import.meta.url)); interface TestResultEntry { originalFile: string; newFile: string; - diffFile: string; } function removeLeadingSlash(s: string): string { @@ -63,8 +62,7 @@ function crawlNewReferenceFiles(testDataPath: string): TestResultEntry[] { } else if (entry.isFile() && entry.name.endsWith('.new.png')) { out.push({ originalFile: `${name}/${entry.name.replace('.new.png', '.png')}`, - newFile: `${name}/${entry.name}`, - diffFile: `${name}/${entry.name.replace('.new.png', '.diff.png')}` + newFile: `${name}/${entry.name}` }); } }