From 79c6ceaa4c09b13f1ee720e015bfaa7633d48a5a Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Thu, 29 Oct 2026 18:22:17 +0100 Subject: [PATCH] fix(rendering): compute beaming bounds for beats without notes Dead-slapped beats of whole duration or longer, and beams made only of dead slaps, dereferenced a missing highest/lowest note and aborted the render. The beaming bounds and beam drawing info now fall back to the beat itself when a helper has no notes. Fixes #2976 --- .../alphatab/src/rendering/LineBarRenderer.ts | 34 ++++++++++++------- .../src/rendering/utils/BeamingHelper.ts | 8 ----- .../visualTests/issues/BrokenRenders.test.ts | 15 ++++++++ 3 files changed, 36 insertions(+), 21 deletions(-) diff --git a/packages/alphatab/src/rendering/LineBarRenderer.ts b/packages/alphatab/src/rendering/LineBarRenderer.ts index 219f06900..bef104015 100644 --- a/packages/alphatab/src/rendering/LineBarRenderer.ts +++ b/packages/alphatab/src/rendering/LineBarRenderer.ts @@ -1008,13 +1008,14 @@ export abstract class LineBarRenderer extends BarRendererBase { if (h.hasTuplet && tupletDirection === direction) { topY -= this.tupletSize + this.tupletOffset; } - const lowestBeat = isRest ? h.beats[0] : h.beatOfLowestNote; + const lowestNote = h.lowestNoteInHelper; + const lowestBeat = lowestNote ? lowestNote.beat : h.beats[0]; if (h.hasTuplet && tupletDirection !== direction) { // Use flag position (matches paintTuplets); getLowestNoteY skips content below the notehead. bottomY = this.getFlagBottomY(lowestBeat, tupletDirection) + this.tupletSize + this.tupletOffset; } else if (isRest) { bottomY = this.getRestY(lowestBeat, NoteYPosition.Bottom); - } else { + } else if (lowestNote) { bottomY = this.voiceContainer.getLowestNoteY(lowestBeat, NoteYPosition.Bottom); } } else { @@ -1022,13 +1023,14 @@ export abstract class LineBarRenderer extends BarRendererBase { if (h.hasTuplet && tupletDirection === direction) { bottomY += this.tupletSize + this.tupletOffset; } - const highestBeat = isRest ? h.beats[0] : h.beatOfHighestNote; + const highestNote = h.highestNoteInHelper; + const highestBeat = highestNote ? highestNote.beat : h.beats[0]; if (h.hasTuplet && tupletDirection !== direction) { // Use flag position (matches paintTuplets); getHighestNoteY skips octave dots. topY = this.getFlagTopY(highestBeat, tupletDirection) - this.tupletSize - this.tupletOffset; } else if (isRest) { topY = this.getRestY(highestBeat, NoteYPosition.Top); - } else { + } else if (highestNote) { topY = this.voiceContainer.getHighestNoteY(highestBeat, NoteYPosition.Top); } } @@ -1287,18 +1289,24 @@ export abstract class LineBarRenderer extends BarRendererBase { // 4. let middle elements shift up/down if (h.beats.length > 1) { // check if highest note shifts bar up or down + const highestNote = h.highestNoteInHelper; + const lowestNote = h.lowestNoteInHelper; if (direction === BeamDirection.Up) { - const yNeededForHighestNote = barDrawingShift + this.getFlagTopY(h.beatOfHighestNote, direction); - const yGivenByCurrentValues = drawingInfo.calcY(this.getBeatX(h.beatOfHighestNote, BeatXPosition.Stem)); + if (highestNote) { + const highestBeat = highestNote.beat; + const yNeededForHighestNote = barDrawingShift + this.getFlagTopY(highestBeat, direction); + const yGivenByCurrentValues = drawingInfo.calcY(this.getBeatX(highestBeat, BeatXPosition.Stem)); - const diff = yGivenByCurrentValues - yNeededForHighestNote; - if (diff > 0) { - drawingInfo.startY -= diff; - drawingInfo.endY -= diff; + const diff = yGivenByCurrentValues - yNeededForHighestNote; + if (diff > 0) { + drawingInfo.startY -= diff; + drawingInfo.endY -= diff; + } } - } else { - const yNeededForLowestNote = barDrawingShift + this.getFlagBottomY(h.beatOfLowestNote, direction); - const yGivenByCurrentValues = drawingInfo.calcY(this.getBeatX(h.beatOfLowestNote, BeatXPosition.Stem)); + } else if (lowestNote) { + const lowestBeat = lowestNote.beat; + const yNeededForLowestNote = barDrawingShift + this.getFlagBottomY(lowestBeat, direction); + const yGivenByCurrentValues = drawingInfo.calcY(this.getBeatX(lowestBeat, BeatXPosition.Stem)); const diff = yNeededForLowestNote - yGivenByCurrentValues; if (diff > 0) { diff --git a/packages/alphatab/src/rendering/utils/BeamingHelper.ts b/packages/alphatab/src/rendering/utils/BeamingHelper.ts index cf005fdbc..77687e53b 100644 --- a/packages/alphatab/src/rendering/utils/BeamingHelper.ts +++ b/packages/alphatab/src/rendering/utils/BeamingHelper.ts @@ -307,14 +307,6 @@ export class BeamingHelper { return ModelUtils.getIndex(a.duration) - 2 - barIndex > 0 && ModelUtils.getIndex(b.duration) - 2 - barIndex > 0; } - public get beatOfLowestNote(): Beat { - return this.lowestNoteInHelper!.beat; - } - - public get beatOfHighestNote(): Beat { - return this.highestNoteInHelper!.beat; - } - /** * Per-direction beam drawing info cache. BeamDirection is a 2-value enum * (Up=0, Down=1) so we use two pre-allocated `BeamingHelperDrawInfo` diff --git a/packages/alphatab/test/visualTests/issues/BrokenRenders.test.ts b/packages/alphatab/test/visualTests/issues/BrokenRenders.test.ts index e91b61470..9f8f83fbb 100644 --- a/packages/alphatab/test/visualTests/issues/BrokenRenders.test.ts +++ b/packages/alphatab/test/visualTests/issues/BrokenRenders.test.ts @@ -160,6 +160,21 @@ describe('BrokenRendersTests', () => { } }); + it('dead-slapped-beats', () => { + for (const tex of [ + '\\staff {score tabs slash numbered} r.1 {ds}', + '\\staff {numbered} \\ts 8 4 :-2 r {ds}', + '\\staff {score tabs slash numbered} \\ts 3 2 :1 C4 {tu 3} C4 {tu 3} r {tu 3 ds}', + '\\staff {score tabs slash numbered} :8 r {ds beam merge} r {ds} r r r r r r' + ]) { + const results = renderSvg(ScoreLoader.loadAlphaTex(tex), 1300); + expect(results.length, tex).toBeGreaterThan(0); + for (const r of results) { + expect(r.includes('NaN'), `NaN in SVG for ${tex}`).toBe(false); + } + } + }); + it('squeezed-bar-warning', () => { const settings = new Settings(); settings.core.engine = 'svg';