@coderline/alphatab 1.9.0-alpha.1768 → 1.9.0-alpha.1803

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/alphaTab.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * alphaTab v1.9.0-alpha.1768 (develop, build 1768)
2
+ * alphaTab v1.9.0-alpha.1803 (develop, build 1803)
3
3
  *
4
4
  * Copyright © 2026, Daniel Kuschny and Contributors, All rights reserved.
5
5
  *
@@ -209,9 +209,9 @@
209
209
  * @internal
210
210
  */
211
211
  class VersionInfo {
212
- static version = '1.9.0-alpha.1768';
213
- static date = '2026-04-10T03:35:57.433Z';
214
- static commit = '759bd788fc094ab5cb4db380f6a7657e459c14bf';
212
+ static version = '1.9.0-alpha.1803';
213
+ static date = '2026-05-15T04:13:04.606Z';
214
+ static commit = 'a87a8635a0a0306cdab1c7fe2e9e72576ed6f795';
215
215
  static print(print) {
216
216
  print(`alphaTab ${VersionInfo.version}`);
217
217
  print(`commit: ${VersionInfo.commit}`);
@@ -998,6 +998,38 @@
998
998
  SlideOutType[SlideOutType["PickSlideUp"] = 6] = "PickSlideUp";
999
999
  })(SlideOutType || (SlideOutType = {}));
1000
1000
 
1001
+ /**
1002
+ * A slur arc spanning two notes, optionally with inner articulation
1003
+ * segments. Corresponds conceptually to a MusicXML `<slur>` element
1004
+ * plus the technique spans inside it.
1005
+ *
1006
+ * For this PR only effect slurs (hammer-pull + legato-slide chains)
1007
+ * are derived in `Note.finish()`. Phrase and legato slurs may join
1008
+ * this type in a future PR; a discriminator will be added at that
1009
+ * point.
1010
+ * @internal
1011
+ */
1012
+ class Slur {
1013
+ originNote;
1014
+ destinationNote;
1015
+ segments = [];
1016
+ }
1017
+
1018
+ /**
1019
+ * Articulation kind for an inner span of a {@link Slur}.
1020
+ *
1021
+ * Drives the renderer's font selection (which {@link NotationElement} to
1022
+ * use) and the default label text when {@link SlurSegment.text} is null.
1023
+ * `Note.finish()` classifies the kind once when building the slur; the
1024
+ * renderer never re-derives it.
1025
+ * @internal
1026
+ */
1027
+ var SlurSegmentKind;
1028
+ (function (SlurSegmentKind) {
1029
+ SlurSegmentKind[SlurSegmentKind["HammerPull"] = 0] = "HammerPull";
1030
+ SlurSegmentKind[SlurSegmentKind["LegatoSlide"] = 1] = "LegatoSlide";
1031
+ })(SlurSegmentKind || (SlurSegmentKind = {}));
1032
+
1001
1033
  /**
1002
1034
  * This public enum lists all vibrato types that can be performed.
1003
1035
  * @public
@@ -1338,6 +1370,14 @@
1338
1370
  * The slurs shown on bend effects within the score staff.
1339
1371
  */
1340
1372
  NotationElement[NotationElement["ScoreBendSlur"] = 55] = "ScoreBendSlur";
1373
+ /**
1374
+ * The hammer-on pull-off text shown on slurs.
1375
+ */
1376
+ NotationElement[NotationElement["EffectHammerOnPullOffText"] = 56] = "EffectHammerOnPullOffText";
1377
+ /**
1378
+ * The slide text shown on slurs.
1379
+ */
1380
+ NotationElement[NotationElement["EffectSlideText"] = 57] = "EffectSlideText";
1341
1381
  })(exports.NotationElement || (exports.NotationElement = {}));
1342
1382
  /**
1343
1383
  * The notation settings control how various music notation elements are shown and behaving
@@ -6134,6 +6174,16 @@
6134
6174
  * @json_ignore
6135
6175
  */
6136
6176
  effectSlurDestination = null;
6177
+ /**
6178
+ * The {@link Slur} object whose origin is this note. Populated by
6179
+ * `finish()`; non-null only on the chain-origin note of an effect
6180
+ * slur. Carries the inner articulation segments used by the
6181
+ * renderer to paint H/P/sl. labels along the arc.
6182
+ * @clone_ignore
6183
+ * @json_ignore
6184
+ * @internal
6185
+ */
6186
+ effectSlur = null;
6137
6187
  /**
6138
6188
  * The ornament applied on the note.
6139
6189
  */
@@ -6418,23 +6468,50 @@
6418
6468
  break;
6419
6469
  }
6420
6470
  let effectSlurDestination = null;
6471
+ let effectSlurSegmentKind = null;
6421
6472
  if (this.isHammerPullOrigin && this.hammerPullDestination) {
6422
6473
  effectSlurDestination = this.hammerPullDestination;
6474
+ effectSlurSegmentKind = SlurSegmentKind.HammerPull;
6423
6475
  }
6424
6476
  else if (this.slideOutType === SlideOutType.Legato && this.slideTarget) {
6425
6477
  effectSlurDestination = this.slideTarget;
6478
+ effectSlurSegmentKind = SlurSegmentKind.LegatoSlide;
6426
6479
  }
6427
6480
  if (effectSlurDestination) {
6428
6481
  this.hasEffectSlur = true;
6429
6482
  if (this.effectSlurOrigin && this.beat.pickStroke === PickStroke.None) {
6430
- this.effectSlurOrigin.effectSlurDestination = effectSlurDestination;
6431
- this.effectSlurOrigin.effectSlurDestination.effectSlurOrigin = this.effectSlurOrigin;
6483
+ const chainOrigin = this.effectSlurOrigin;
6484
+ chainOrigin.effectSlurDestination = effectSlurDestination;
6485
+ effectSlurDestination.effectSlurOrigin = chainOrigin;
6432
6486
  this.effectSlurOrigin = null;
6487
+ if (effectSlurSegmentKind !== null && chainOrigin.effectSlur !== null) {
6488
+ chainOrigin.effectSlur.destinationNote = effectSlurDestination;
6489
+ chainOrigin.effectSlur.segments.push({
6490
+ fromNote: this,
6491
+ toNote: effectSlurDestination,
6492
+ kind: effectSlurSegmentKind,
6493
+ text: null
6494
+ });
6495
+ }
6433
6496
  }
6434
6497
  else {
6435
6498
  this.isEffectSlurOrigin = true;
6436
6499
  this.effectSlurDestination = effectSlurDestination;
6437
- this.effectSlurDestination.effectSlurOrigin = this;
6500
+ effectSlurDestination.effectSlurOrigin = this;
6501
+ // Always allocate a fresh Slur — finish() may run twice (worker re-finish);
6502
+ // overwriting unconditionally keeps the derivation idempotent.
6503
+ const slur = new Slur();
6504
+ slur.originNote = this;
6505
+ slur.destinationNote = effectSlurDestination;
6506
+ if (effectSlurSegmentKind !== null) {
6507
+ slur.segments.push({
6508
+ fromNote: this,
6509
+ toNote: effectSlurDestination,
6510
+ kind: effectSlurSegmentKind,
6511
+ text: null
6512
+ });
6513
+ }
6514
+ this.effectSlur = slur;
6438
6515
  }
6439
6516
  }
6440
6517
  // try to detect what kind of bend was used and cleans unneeded points if required
@@ -7684,6 +7761,23 @@
7684
7761
  * @json_ignore
7685
7762
  */
7686
7763
  effectSlurDestination = null;
7764
+ /**
7765
+ * Convenience accessor for the {@link Slur} of this beat. Returns
7766
+ * the effect slur of whichever note in this beat owns it (the
7767
+ * chain-origin note populated during `Note.finish()`), or `null`
7768
+ * when no note in the beat is an effect-slur origin.
7769
+ * @clone_ignore
7770
+ * @json_ignore
7771
+ * @internal
7772
+ */
7773
+ get effectSlur() {
7774
+ for (const n of this.notes) {
7775
+ if (n.effectSlur !== null) {
7776
+ return n.effectSlur;
7777
+ }
7778
+ }
7779
+ return null;
7780
+ }
7687
7781
  /**
7688
7782
  * Gets or sets how the beaming should be done for this beat.
7689
7783
  */
@@ -31948,7 +32042,9 @@
31948
32042
  [exports.NotationElement.RepeatCount, new Font(RenderingResources._sansFont, 11, FontStyle.Plain)],
31949
32043
  [exports.NotationElement.BarNumber, new Font(RenderingResources._sansFont, 11, FontStyle.Plain)],
31950
32044
  [exports.NotationElement.ScoreBendSlur, new Font(RenderingResources._sansFont, 11, FontStyle.Plain)],
31951
- [exports.NotationElement.EffectAlternateEndings, new Font(RenderingResources._serifFont, 15, FontStyle.Plain)]
32045
+ [exports.NotationElement.EffectAlternateEndings, new Font(RenderingResources._serifFont, 15, FontStyle.Plain)],
32046
+ [exports.NotationElement.EffectHammerOnPullOffText, RenderingResources._effectFont],
32047
+ [exports.NotationElement.EffectSlideText, RenderingResources._effectFont]
31952
32048
  ]);
31953
32049
  /**
31954
32050
  * The name of the SMuFL Font to use for rendering music symbols.
@@ -32239,9 +32335,16 @@
32239
32335
  notationElement = exports.NotationElement.ScoreWords;
32240
32336
  break;
32241
32337
  }
32338
+ return this.getFontForNotationElement(notationElement);
32339
+ }
32340
+ /**
32341
+ * @internal
32342
+ * @param element
32343
+ */
32344
+ getFontForNotationElement(notationElement) {
32242
32345
  return this.elementFonts.has(notationElement)
32243
32346
  ? this.elementFonts.get(notationElement)
32244
- : RenderingResources.defaultFonts.get(exports.NotationElement.ScoreWords);
32347
+ : RenderingResources.defaultFonts.get(notationElement);
32245
32348
  }
32246
32349
  }
32247
32350
 
@@ -32634,9 +32737,16 @@
32634
32737
  *
32635
32738
  * The page layout does not use `displayWidth`. The use of absolute widths would break the proper alignments needed for this kind of display.
32636
32739
  *
32637
- * Also note that the sizing is including any glyphs and notation elements within the bar. e.g. if there are clefs in the bar, they are still "squeezed" into the available size.
32638
- * It is not the case that the actual notes with their lengths are sized accordingly. This fits the sizing system of Guitar Pro and when files are customized there,
32639
- * alphaTab will match this layout quite close.
32740
+ * In both modes, prefix and postfix glyphs (clef, key signature, time signature, barlines) are treated as fixed overhead: they keep their
32741
+ * natural size and the remaining staff width is distributed across bars by a per-bar weight. This matches the convention used by
32742
+ * Guitar Pro, Dorico, Finale, Sibelius and MuseScore. Bars that carry a system-start prefix or a mid-line clef/key/time-signature change
32743
+ * are therefore visibly wider than plain bars with the same weight. The weight source depends on the mode:
32744
+ *
32745
+ * * `Automatic` (default for `page` layout): weights come from the built-in spacing engine (the natural content width of each bar).
32746
+ * `displayScale` on the model is ignored.
32747
+ * * `UseModelLayout` (and the `parchment` layout): weights come from `bar.displayScale` / `masterBar.displayScale`. An unset
32748
+ * `displayScale` defaults to `1` and behaves identically to an explicit `1`, matching Guitar Pro (which omits the value when the
32749
+ * author hasn't customized it).
32640
32750
  *
32641
32751
  * ### Horizontal Layout
32642
32752
  *
@@ -38835,14 +38945,25 @@
38835
38945
  */
38836
38946
  boundsLookup;
38837
38947
  /**
38838
- * Finished the lookup for optimized access.
38948
+ * Whether this system's bounds have already been scaled via `finish`. Prevents double-scaling
38949
+ * when the parent `BoundsLookup` is preserved across partial renders and `finish` is invoked
38950
+ * again on a mix of already-scaled (preserved) and newly-registered (natural-coordinate) systems.
38951
+ */
38952
+ isFinished = false;
38953
+ /**
38954
+ * Finished the lookup for optimized access. Idempotent: once finished, further calls are no-ops
38955
+ * so preserved systems survive partial renders without being re-scaled.
38839
38956
  */
38840
38957
  finish(scale = 1) {
38958
+ if (this.isFinished) {
38959
+ return;
38960
+ }
38841
38961
  this.realBounds.scaleWith(scale);
38842
38962
  this.visualBounds.scaleWith(scale);
38843
38963
  for (const t of this.bars) {
38844
38964
  t.finish(scale);
38845
38965
  }
38966
+ this.isFinished = true;
38846
38967
  }
38847
38968
  /**
38848
38969
  * Adds a new master bar to this lookup.
@@ -39015,6 +39136,58 @@
39015
39136
  }
39016
39137
  this.isFinished = true;
39017
39138
  }
39139
+ /**
39140
+ * Re-opens the lookup for registrations without discarding previously registered bounds.
39141
+ * Used by the renderer when it preserves this lookup across a partial render so that new
39142
+ * bounds for the re-layouted range can be added while preserved systems stay intact.
39143
+ * @internal
39144
+ */
39145
+ resetForPartialUpdate() {
39146
+ this.isFinished = false;
39147
+ }
39148
+ /**
39149
+ * Removes all entries belonging to the given master bar index and any bars after it.
39150
+ * Used before a partial render re-registers bounds for the re-layouted range, so the
39151
+ * preserved lookup ends up with only the unchanged entries when registration begins.
39152
+ *
39153
+ * Assumes the layout aligns its re-layouted range to system boundaries - i.e. the first
39154
+ * system to clear starts exactly at `masterBarIndex`. Caller is responsible for passing
39155
+ * the first master-bar-index of the first re-layouted system.
39156
+ * @internal
39157
+ */
39158
+ clearFromMasterBar(masterBarIndex) {
39159
+ // drop staff systems whose bars start at or after the cleared range.
39160
+ let firstRemovedSystem = -1;
39161
+ for (let i = 0; i < this.staffSystems.length; i++) {
39162
+ const systemBars = this.staffSystems[i].bars;
39163
+ if (systemBars.length > 0 && systemBars[0].index >= masterBarIndex) {
39164
+ firstRemovedSystem = i;
39165
+ break;
39166
+ }
39167
+ }
39168
+ if (firstRemovedSystem !== -1) {
39169
+ this.staffSystems.splice(firstRemovedSystem, this.staffSystems.length - firstRemovedSystem);
39170
+ }
39171
+ // drop master bar entries at or beyond the cleared range.
39172
+ for (const key of Array.from(this._masterBarLookup.keys())) {
39173
+ if (key >= masterBarIndex) {
39174
+ this._masterBarLookup.delete(key);
39175
+ }
39176
+ }
39177
+ // drop beat entries whose beats belong to cleared bars.
39178
+ for (const key of Array.from(this._beatLookup.keys())) {
39179
+ const list = this._beatLookup.get(key);
39180
+ const filtered = list.filter(b => b.beat.voice.bar.index < masterBarIndex);
39181
+ if (filtered.length === 0) {
39182
+ this._beatLookup.delete(key);
39183
+ }
39184
+ else if (filtered.length !== list.length) {
39185
+ this._beatLookup.set(key, filtered);
39186
+ }
39187
+ }
39188
+ // drop the in-progress pointer - the next addStaffSystem call will replace it.
39189
+ this._currentStaffSystem = null;
39190
+ }
39018
39191
  /**
39019
39192
  * Adds a new staff sytem to the lookup.
39020
39193
  * @param bounds The staff system bounds to add.
@@ -46846,7 +47019,15 @@
46846
47019
  Logger.warning('Rendering', 'AlphaTab skipped rendering because of width=0 (element invisible)', null);
46847
47020
  return;
46848
47021
  }
46849
- this.boundsLookup = new BoundsLookup();
47022
+ // For partial renders we preserve the existing lookup so bars outside the re-layouted
47023
+ // range keep their already-scaled bounds - the layout will clear the changed range
47024
+ // before the paint pass re-registers fresh entries for it.
47025
+ if (renderHints?.firstChangedMasterBar !== undefined && this.boundsLookup) {
47026
+ this.boundsLookup.resetForPartialUpdate();
47027
+ }
47028
+ else {
47029
+ this.boundsLookup = new BoundsLookup();
47030
+ }
46850
47031
  this._recreateCanvas();
46851
47032
  this.canvas.lineWidth = 1;
46852
47033
  this.canvas.settings = this.settings;
@@ -48720,9 +48901,6 @@
48720
48901
  * @param beat The beat to add.
48721
48902
  */
48722
48903
  highlightBeat(beat, playbackStart) {
48723
- if (beat.isEmpty && !beat.voice.isEmpty) {
48724
- return;
48725
- }
48726
48904
  if (!this._highlightedBeats.has(beat.id)) {
48727
48905
  this._highlightedBeats.set(beat.id, true);
48728
48906
  this.highlightedBeats.push(new BeatTickLookupItem(beat, playbackStart));
@@ -49092,6 +49270,21 @@
49092
49270
  }
49093
49271
  }
49094
49272
 
49273
+ /**
49274
+ * Represents a range of the song that should be played.
49275
+ * @public
49276
+ */
49277
+ class PlaybackRange {
49278
+ /**
49279
+ * The position in midi ticks from where the song should start.
49280
+ */
49281
+ startTick = 0;
49282
+ /**
49283
+ * The position in midi ticks to where the song should be played.
49284
+ */
49285
+ endTick = 0;
49286
+ }
49287
+
49095
49288
  /**
49096
49289
  * Describes how a cursor should be moving.
49097
49290
  * @public
@@ -49252,6 +49445,13 @@
49252
49445
  * @internal
49253
49446
  */
49254
49447
  masterBarLookup = new Map();
49448
+ /**
49449
+ * A dictionary of all beat played. The index is the id to {@link Beat.id}.
49450
+ * The value is the bar relative tick time at which the beat was registered during midi generation.
49451
+ * This lookup only contains the first time a Beat is played.
49452
+ * @internal
49453
+ */
49454
+ beatLookup = new Map();
49255
49455
  /**
49256
49456
  * A list of all {@link MasterBarTickLookup} sorted by time.
49257
49457
  */
@@ -49618,10 +49818,22 @@
49618
49818
  * @returns The time in midi ticks at which the beat is played the first time or 0 if the beat is not contained
49619
49819
  */
49620
49820
  getBeatStart(beat) {
49621
- if (!this.masterBarLookup.has(beat.voice.bar.index)) {
49821
+ if (!this.masterBarLookup.has(beat.voice.bar.index) || !this.beatLookup.has(beat.id)) {
49622
49822
  return 0;
49623
49823
  }
49624
- return this.masterBarLookup.get(beat.voice.bar.index).start + beat.playbackStart;
49824
+ const mb = this.masterBarLookup.get(beat.voice.bar.index);
49825
+ return mb.start + this.beatLookup.get(beat.id).startTick;
49826
+ }
49827
+ /**
49828
+ * Gets the playback range in midi ticks for a given beat.
49829
+ * @param beat The beat to find the time period for.
49830
+ * @returns The relative playback range within the parent masterbar at which the beat start and ends playing
49831
+ */
49832
+ getRelativeBeatPlaybackRange(beat) {
49833
+ if (!this.beatLookup.has(beat.id)) {
49834
+ return undefined;
49835
+ }
49836
+ return this.beatLookup.get(beat.id);
49625
49837
  }
49626
49838
  /**
49627
49839
  * Adds a new {@link MasterBarTickLookup} to the lookup table.
@@ -49639,6 +49851,12 @@
49639
49851
  }
49640
49852
  }
49641
49853
  addBeat(beat, start, duration) {
49854
+ if (!this.beatLookup.has(beat.id)) {
49855
+ const playbackRange = new PlaybackRange();
49856
+ playbackRange.startTick = start;
49857
+ playbackRange.endTick = start + duration;
49858
+ this.beatLookup.set(beat.id, playbackRange);
49859
+ }
49642
49860
  const currentMasterBar = this._currentMasterBar;
49643
49861
  if (currentMasterBar) {
49644
49862
  // pre-beat grace notes at the start of the bar we also add the beat to the previous bar
@@ -50190,7 +50408,13 @@
50190
50408
  let beatStart = beat.playbackStart;
50191
50409
  let audioDuration = beat.playbackDuration;
50192
50410
  const masterBarDuration = beat.voice.bar.masterBar.calculateDuration();
50193
- if (beat.voice.bar.isEmpty) {
50411
+ // For a bar whose voice contains a single empty beat (the typical "whole-bar rest"
50412
+ // placeholder inserted during score.finish), extend the beat's audio duration to cover
50413
+ // the full bar so cursor navigation has a beat to follow across the whole bar. Don't
50414
+ // apply this when the voice has multiple beats: those represent explicit rhythmic
50415
+ // subdivisions even when each beat is empty (e.g. a recording grid of placeholder
50416
+ // slots), and overriding would make every beat overlap the whole bar.
50417
+ if (beat.voice.bar.isEmpty && beat.voice.beats.length === 1) {
50194
50418
  audioDuration = masterBarDuration;
50195
50419
  }
50196
50420
  else if (beat.voice.bar.masterBar.tripletFeel !== TripletFeel.NoTripletFeel &&
@@ -52216,21 +52440,6 @@
52216
52440
  }
52217
52441
  }
52218
52442
 
52219
- /**
52220
- * Represents a range of the song that should be played.
52221
- * @public
52222
- */
52223
- class PlaybackRange {
52224
- /**
52225
- * The position in midi ticks from where the song should start.
52226
- */
52227
- startTick = 0;
52228
- /**
52229
- * The position in midi ticks to where the song should be played.
52230
- */
52231
- endTick = 0;
52232
- }
52233
-
52234
52443
  /**
52235
52444
  * A {@link IAlphaSynth} implementation wrapping and underling other {@link IAlphaSynth}
52236
52445
  * allowing dynamic changing of the underlying instance without loosing aspects like the
@@ -54811,7 +55020,8 @@
54811
55020
  this._isInitialBeatCursorUpdate ||
54812
55021
  barBounds.y !== previousBeatBounds.barBounds.masterBarBounds.visualBounds.y ||
54813
55022
  startBeatX < previousBeatBounds.onNotesX ||
54814
- barBoundings.index > previousBeatBounds.barBounds.masterBarBounds.index + 1;
55023
+ barBoundings.index > previousBeatBounds.barBounds.masterBarBounds.index + 1 ||
55024
+ barBounds.h !== previousBeatBounds.barBounds.masterBarBounds.visualBounds.h;
54815
55025
  if (jumpCursor) {
54816
55026
  cursorHandler.placeBeatCursor(beatCursor, beatBoundings, startBeatX);
54817
55027
  }
@@ -55430,23 +55640,24 @@
55430
55640
  if (this._selectionStart && this._tickCache) {
55431
55641
  // get the start and stop ticks (which consider properly repeats)
55432
55642
  const tickCache = this._tickCache;
55433
- const realMasterBarStart = tickCache.getMasterBarStart(this._selectionStart.beat.voice.bar.masterBar);
55643
+ const realStartMasterBarStart = tickCache.getMasterBarStart(this._selectionStart.beat.voice.bar.masterBar);
55644
+ const startBeatPlaybackRange = tickCache.getRelativeBeatPlaybackRange(this._selectionStart.beat);
55645
+ const startBeatPlaybackStart = startBeatPlaybackRange?.startTick ?? this._selectionStart.beat.playbackStart;
55434
55646
  // move to selection start
55435
55647
  this._currentBeat = null; // reset current beat so it is updating the cursor
55436
55648
  if (this._player.state === PlayerState.Paused) {
55437
- this._cursorUpdateTick(this._tickCache.getBeatStart(this._selectionStart.beat), false, 1);
55649
+ this._cursorUpdateTick(realStartMasterBarStart + startBeatPlaybackStart, false, 1);
55438
55650
  }
55439
- this.tickPosition = realMasterBarStart + this._selectionStart.beat.playbackStart;
55651
+ this.tickPosition = realStartMasterBarStart + startBeatPlaybackStart;
55440
55652
  // set playback range
55441
55653
  if (this._selectionEnd && this._selectionStart.beat !== this._selectionEnd.beat) {
55442
- const realMasterBarEnd = tickCache.getMasterBarStart(this._selectionEnd.beat.voice.bar.masterBar);
55654
+ const realEndMasterBarStart = tickCache.getMasterBarStart(this._selectionEnd.beat.voice.bar.masterBar);
55655
+ const endBeatPlaybackRange = tickCache.getRelativeBeatPlaybackRange(this._selectionEnd.beat);
55656
+ const endBeatPlaybackEnd = endBeatPlaybackRange?.endTick ??
55657
+ this._selectionEnd.beat.playbackStart + this._selectionEnd.beat.playbackDuration;
55443
55658
  const range = new PlaybackRange();
55444
- range.startTick = realMasterBarStart + this._selectionStart.beat.playbackStart;
55445
- range.endTick =
55446
- realMasterBarEnd +
55447
- this._selectionEnd.beat.playbackStart +
55448
- this._selectionEnd.beat.playbackDuration -
55449
- 50;
55659
+ range.startTick = realStartMasterBarStart + startBeatPlaybackStart;
55660
+ range.endTick = realEndMasterBarStart + endBeatPlaybackEnd - 50;
55450
55661
  this.playbackRange = range;
55451
55662
  }
55452
55663
  else {
@@ -58051,8 +58262,9 @@
58051
58262
  break;
58052
58263
  case 'alphaTab.renderScore':
58053
58264
  this._updateFontSizes(data.fontSizes);
58265
+ const renderHints = data.renderHints;
58054
58266
  const score = data.score == null ? null : JsonConverter.jsObjectToScore(data.score, this._renderer.settings);
58055
- this._renderMultiple(score, data.trackIndexes);
58267
+ this._renderMultiple(score, data.trackIndexes, renderHints);
58056
58268
  break;
58057
58269
  case 'alphaTab.updateSettings':
58058
58270
  this._updateSettings(data.settings);
@@ -61492,6 +61704,12 @@
61492
61704
  _tieHeight = 0;
61493
61705
  _boundingBox;
61494
61706
  _shouldPaint = false;
61707
+ // Resolved per-label paint state. Lazily grown; re-layouts mutate
61708
+ // existing entries in place and update `_resolvedLabelCount` to
61709
+ // signal how many of them are valid this pass.
61710
+ _resolvedLabels = [];
61711
+ _resolvedLabelCount = 0;
61712
+ _labelBaselineOffset = 0;
61495
61713
  get checkForOverflow() {
61496
61714
  return this._shouldPaint && this._boundingBox !== undefined;
61497
61715
  }
@@ -61561,16 +61779,88 @@
61561
61779
  }
61562
61780
  this._boundingBox = undefined;
61563
61781
  this.y = Math.min(this._startY, this._endY);
61782
+ const down = this.tieDirection === BeamDirection.Down;
61564
61783
  let tieBoundingBox;
61784
+ // Bezier control points for the tie. Computed once and reused
61785
+ // for both the bounding box (via _calculateActualTieHeightFromCps)
61786
+ // and label-apex sampling further below — avoids a redundant
61787
+ // call to _computeBezierControlPoints (and its 14-element array
61788
+ // allocation) per labeled slur per layout.
61789
+ let cps = [];
61565
61790
  if (this.shouldDrawBendSlur()) {
61566
61791
  this._tieHeight = 0;
61567
- tieBoundingBox = TieGlyph.calculateBendSlurHeight(this._startX, this._startY, this._endX, this._endY, this.tieDirection === BeamDirection.Down, this.renderer.smuflMetrics.tieHeight);
61792
+ tieBoundingBox = TieGlyph.calculateBendSlurHeight(this._startX, this._startY, this._endX, this._endY, down, this.renderer.smuflMetrics.tieHeight);
61568
61793
  }
61569
61794
  else {
61570
61795
  this._tieHeight = this.getTieHeight(this._startX, this._startY, this._endX, this._endY);
61571
- tieBoundingBox = TieGlyph.calculateActualTieHeight(1, this._startX, this._startY, this._endX, this._endY, this.tieDirection === BeamDirection.Down, this._tieHeight, this.renderer.smuflMetrics.tieMidpointThickness);
61796
+ const tieThickness = this.renderer.smuflMetrics.tieMidpointThickness;
61797
+ cps = TieGlyph._computeBezierControlPoints(1, this._startX, this._startY, this._endX, this._endY, down, this._tieHeight, tieThickness);
61798
+ tieBoundingBox = TieGlyph._calculateActualTieHeightFromCps(cps, this._startX, this._startY, this._endX, this._endY, down, tieThickness);
61572
61799
  }
61573
61800
  this._boundingBox = tieBoundingBox;
61801
+ this._resolvedLabelCount = 0;
61802
+ const labels = this.getSlurLabels();
61803
+ if (labels !== null && labels.length > 0 && this.shouldPaintLabels()) {
61804
+ const res = this.renderer.settings.display.resources;
61805
+ const padding = this.renderer.smuflMetrics.oneStaffSpace * 0.25;
61806
+ let maxTextHeight = 0;
61807
+ // Single Y line for all labels — the outer arc apex.
61808
+ // Painted offset adds `padding` on the outward side, so
61809
+ // every label sits the same fixed distance from its arc.
61810
+ const labelLineY = cps.length > 0
61811
+ ? 0.125 * cps[7] + 0.375 * cps[9] + 0.375 * cps[11] + 0.125 * cps[13]
61812
+ : (this._startY + this._endY) / 2;
61813
+ for (const label of labels) {
61814
+ const fromX = this.resolveLabelAnchorX(label.fromNote);
61815
+ const toX = this.resolveLabelAnchorX(label.toNote);
61816
+ if (fromX === null || toX === null) {
61817
+ continue;
61818
+ }
61819
+ const midX = (fromX + toX) / 2;
61820
+ if (midX < this._startX || midX > this._endX) {
61821
+ continue;
61822
+ }
61823
+ // Per-element font.size as an upper bound on glyph
61824
+ // height — avoids per-label measureText calls. All H/P
61825
+ // and sl. labels use the same _effectFont, so this is
61826
+ // typically computed once.
61827
+ const font = res.getFontForNotationElement(label.element);
61828
+ if (font.size > maxTextHeight) {
61829
+ maxTextHeight = font.size;
61830
+ }
61831
+ // grow cache lazily; mutate existing slot in place otherwise
61832
+ let slot;
61833
+ if (this._resolvedLabelCount < this._resolvedLabels.length) {
61834
+ slot = this._resolvedLabels[this._resolvedLabelCount];
61835
+ slot.x = midX;
61836
+ slot.y = labelLineY;
61837
+ slot.text = label.text;
61838
+ slot.element = label.element;
61839
+ }
61840
+ else {
61841
+ slot = {
61842
+ x: midX,
61843
+ y: labelLineY,
61844
+ text: label.text,
61845
+ element: label.element
61846
+ };
61847
+ this._resolvedLabels.push(slot);
61848
+ }
61849
+ this._resolvedLabelCount++;
61850
+ }
61851
+ if (this._resolvedLabelCount > 0) {
61852
+ // canvas.textBaseline is 'hanging' (TextBaseline.Top), so
61853
+ // fillText positions `y` at the glyph's top edge.
61854
+ if (this.tieDirection === BeamDirection.Up) {
61855
+ tieBoundingBox.y -= maxTextHeight + padding;
61856
+ this._labelBaselineOffset = -(maxTextHeight + padding);
61857
+ }
61858
+ else {
61859
+ this._labelBaselineOffset = padding;
61860
+ }
61861
+ tieBoundingBox.h += maxTextHeight + padding;
61862
+ }
61863
+ }
61574
61864
  this.height = tieBoundingBox.h;
61575
61865
  if (this.tieDirection === BeamDirection.Up) {
61576
61866
  // the tie might go above `this.y` due to its shape
@@ -61586,13 +61876,77 @@
61586
61876
  if (!this._shouldPaint) {
61587
61877
  return;
61588
61878
  }
61879
+ const isDown = this.tieDirection === BeamDirection.Down;
61589
61880
  if (this.shouldDrawBendSlur()) {
61590
- TieGlyph.drawBendSlur(canvas, cx + this._startX, cy + this._startY, cx + this._endX, cy + this._endY, this.tieDirection === BeamDirection.Down, this.renderer.smuflMetrics.tieHeight);
61881
+ TieGlyph.drawBendSlur(canvas, cx + this._startX, cy + this._startY, cx + this._endX, cy + this._endY, isDown, this.renderer.smuflMetrics.tieHeight);
61591
61882
  }
61592
61883
  else {
61593
- TieGlyph.paintTie(canvas, 1, cx + this._startX, cy + this._startY, cx + this._endX, cy + this._endY, this.tieDirection === BeamDirection.Down, this._tieHeight, this.renderer.smuflMetrics.tieMidpointThickness);
61884
+ TieGlyph.paintTie(canvas, 1, cx + this._startX, cy + this._startY, cx + this._endX, cy + this._endY, isDown, this._tieHeight, this.renderer.smuflMetrics.tieMidpointThickness);
61885
+ }
61886
+ if (this._resolvedLabelCount > 0) {
61887
+ const ta = canvas.textAlign;
61888
+ const tb = canvas.textBaseline;
61889
+ canvas.textAlign = TextAlign.Center;
61890
+ canvas.textBaseline = TextBaseline.Top;
61891
+ const res = this.renderer.resources;
61892
+ let lastElement = -1;
61893
+ for (let i = 0; i < this._resolvedLabelCount; i++) {
61894
+ const label = this._resolvedLabels[i];
61895
+ if (label.element !== lastElement) {
61896
+ canvas.font = res.getFontForNotationElement(label.element);
61897
+ lastElement = label.element;
61898
+ }
61899
+ canvas.fillText(label.text, cx + label.x, cy + label.y + this._labelBaselineOffset);
61900
+ }
61901
+ canvas.textAlign = ta;
61902
+ canvas.textBaseline = tb;
61594
61903
  }
61595
61904
  }
61905
+ /**
61906
+ * Returns the labels to paint along this slur, or `null` when there
61907
+ * are none. Override in subclasses.
61908
+ */
61909
+ getSlurLabels() {
61910
+ return null;
61911
+ }
61912
+ /**
61913
+ * Whether label painting is enabled. Defaults to `true`. Subclasses
61914
+ * may override to disable labels on the bend-slur path or other
61915
+ * special cases.
61916
+ */
61917
+ shouldPaintLabels() {
61918
+ return !this.shouldDrawBendSlur();
61919
+ }
61920
+ /**
61921
+ * Looks up the absolute X coordinate of an anchor note. Reuses
61922
+ * the start/end bar renderers already resolved by the subclass
61923
+ * (NoteTieGlyph) when the note's bar matches — most labels live
61924
+ * in the slur's start or end bar, so this avoids the double Map
61925
+ * lookup in `getRendererForBar` per label per layout. Returns
61926
+ * `null` when the note's bar is not rendered on this glyph's
61927
+ * staff (cross-system case).
61928
+ */
61929
+ resolveLabelAnchorX(note) {
61930
+ const bar = note.beat.voice.bar;
61931
+ let renderer = null;
61932
+ const start = this.lookupStartBeatRenderer();
61933
+ if (start !== null && start.bar === bar) {
61934
+ renderer = start;
61935
+ }
61936
+ else {
61937
+ const end = this.lookupEndBeatRenderer();
61938
+ if (end !== null && end.bar === bar) {
61939
+ renderer = end;
61940
+ }
61941
+ else {
61942
+ renderer = this.renderer.scoreRenderer.layout.getRendererForBar(this.renderer.staff.staffId, bar);
61943
+ }
61944
+ }
61945
+ if (renderer === null) {
61946
+ return null;
61947
+ }
61948
+ return renderer.x + renderer.getNoteX(note, NoteXPosition.Center);
61949
+ }
61596
61950
  getTieHeight(_startX, _startY, _endX, _endY) {
61597
61951
  return this.renderer.smuflMetrics.tieHeight;
61598
61952
  }
@@ -61611,11 +61965,18 @@
61611
61965
  }
61612
61966
  static calculateActualTieHeight(scale, x1, y1, x2, y2, down, offset, size) {
61613
61967
  const cp = TieGlyph._computeBezierControlPoints(scale, x1, y1, x2, y2, down, offset, size);
61968
+ return TieGlyph._calculateActualTieHeightFromCps(cp, x1, y1, x2, y2, down, size);
61969
+ }
61970
+ /**
61971
+ * Derives the bounding box for a tie from already-computed control
61972
+ * points. Splits the bbox math from cps generation so callers that
61973
+ * need BOTH cps and bbox (e.g. multi-label slur layout) avoid a
61974
+ * second call to `_computeBezierControlPoints`.
61975
+ */
61976
+ static _calculateActualTieHeightFromCps(cp, x1, y1, x2, y2, down, size) {
61614
61977
  if (cp.length === 0) {
61615
61978
  return new Bounds(x1, y1, x2 - x1, y2 - y1);
61616
61979
  }
61617
- // For a musical tie/slur, the extrema occur predictably near the midpoint
61618
- // Evaluate at midpoint (t=0.5) and check endpoints
61619
61980
  const p0x = cp[0];
61620
61981
  const p0y = cp[1];
61621
61982
  const c1x = cp[2];
@@ -61624,15 +61985,12 @@
61624
61985
  const c2y = cp[5];
61625
61986
  const p1x = cp[6];
61626
61987
  const p1y = cp[7];
61627
- // Evaluate at t=0.5 for midpoint
61628
61988
  const midX = 0.125 * p0x + 0.375 * c1x + 0.375 * c2x + 0.125 * p1x;
61629
61989
  const midY = 0.125 * p0y + 0.375 * c1y + 0.375 * c2y + 0.125 * p1y;
61630
- // Bounds are simply min/max of start, end, and midpoint
61631
61990
  const xMin = Math.min(p0x, p1x, midX);
61632
61991
  const xMax = Math.max(p0x, p1x, midX);
61633
61992
  let yMin = Math.min(p0y, p1y, midY);
61634
61993
  let yMax = Math.max(p0y, p1y, midY);
61635
- // Account for thickness of the tie/slur
61636
61994
  if (down) {
61637
61995
  yMax += size;
61638
61996
  }
@@ -62925,6 +63283,15 @@
62925
63283
  }
62926
63284
  return false;
62927
63285
  }
63286
+ /**
63287
+ * The fixed-overhead width of this renderer: glyphs that do not stretch when
63288
+ * the bar is scaled (clef, key signature, time signature, barlines, courtesy
63289
+ * accidentals, etc). Treated as a fixed allocation by the system-level layout
63290
+ * before distributing remaining width across bars by {@link Bar.displayScale}.
63291
+ */
63292
+ get fixedOverhead() {
63293
+ return this._preBeatGlyphs.width + this._postBeatGlyphs.width;
63294
+ }
62928
63295
  scaleToWidth(width) {
62929
63296
  // preBeat and postBeat glyphs do not get resized
62930
63297
  const containerWidth = width - this._preBeatGlyphs.width - this._postBeatGlyphs.width;
@@ -65483,6 +65850,22 @@
65483
65850
  postBeatSize = 0;
65484
65851
  minStretchForce = 0;
65485
65852
  totalSpringConstant = 0;
65853
+ /**
65854
+ * The smallest note duration encountered within this bar's springs, used as the reference in
65855
+ * the Gourlay stretch formula. Read by the owning {@link StaffSystem} so that the system can
65856
+ * aggregate a shared minimum across all bars and trigger a reconcile if an added bar introduces
65857
+ * a shorter duration than previously seen.
65858
+ */
65859
+ get localMinDuration() {
65860
+ return this._minDuration;
65861
+ }
65862
+ /**
65863
+ * The minimum-duration reference against which the spring constants currently held by this info
65864
+ * were computed. Set by {@link finish} and {@link recomputeSpringConstants}. The owning
65865
+ * StaffSystem compares this against its system-wide minimum to decide whether spring constants
65866
+ * need re-derivation.
65867
+ */
65868
+ computedWithMinDuration = 0;
65486
65869
  _updateMinStretchForce(force) {
65487
65870
  if (this.minStretchForce < force) {
65488
65871
  this.minStretchForce = force;
@@ -65652,10 +66035,26 @@
65652
66035
  this._incompleteGraceRodsWidth += sp.preBeatWidth + sp.postSpringWidth;
65653
66036
  }
65654
66037
  }
65655
- this._calculateSpringConstants();
66038
+ this._calculateSpringConstants(this._minDuration);
66039
+ this.computedWithMinDuration = this._minDuration;
65656
66040
  this.version++;
65657
66041
  }
65658
- _calculateSpringConstants() {
66042
+ /**
66043
+ * Re-derives the spring constants (and {@link minStretchForce} / {@link totalSpringConstant})
66044
+ * using a caller-supplied minimum-duration reference rather than this bar's local minimum.
66045
+ *
66046
+ * Called by {@link StaffSystem.reconcileMinDurationIfDirty} when a bar added later to the
66047
+ * system introduced a shorter note than previously seen, invalidating this bar's spring
66048
+ * constants. Grace-rod data is not recomputed — it is independent of the minimum-duration
66049
+ * reference. The internal {@link version} is bumped so downstream consumers (e.g.
66050
+ * {@link BarRendererBase.applyLayoutingInfo}) pick up the refreshed positions.
66051
+ */
66052
+ recomputeSpringConstants(minDuration) {
66053
+ this._calculateSpringConstants(minDuration);
66054
+ this.computedWithMinDuration = minDuration;
66055
+ this.version++;
66056
+ }
66057
+ _calculateSpringConstants(minDuration) {
65659
66058
  let totalSpringConstant = 0;
65660
66059
  const sortedSprings = this._timeSortedSprings;
65661
66060
  if (sortedSprings.length === 0) {
@@ -65673,7 +66072,7 @@
65673
66072
  const nextSpring = sortedSprings[i + 1];
65674
66073
  duration = Math.abs(nextSpring.timePosition - currentSpring.timePosition);
65675
66074
  }
65676
- currentSpring.springConstant = this._calculateSpringConstant(currentSpring, duration);
66075
+ currentSpring.springConstant = this._calculateSpringConstant(currentSpring, duration, minDuration);
65677
66076
  totalSpringConstant += 1 / currentSpring.springConstant;
65678
66077
  }
65679
66078
  this.totalSpringConstant = 1 / totalSpringConstant;
@@ -65733,7 +66132,7 @@
65733
66132
  // springX += this.calculateWidth(force, spring.springConstant);
65734
66133
  // }
65735
66134
  // }
65736
- _calculateSpringConstant(spring, duration) {
66135
+ _calculateSpringConstant(spring, duration, minDuration) {
65737
66136
  if (duration <= 0) {
65738
66137
  duration = MidiUtils.toTicks(Duration.TwoHundredFiftySixth);
65739
66138
  }
@@ -65741,7 +66140,6 @@
65741
66140
  spring.smallestDuration = duration;
65742
66141
  }
65743
66142
  const smallestDuration = spring.smallestDuration;
65744
- const minDuration = this._minDuration;
65745
66143
  const minDurationWidth = BarLayoutingInfo._defaultMinDurationWidth;
65746
66144
  const phi = 1 + 0.85 * Math.log2(duration / minDuration);
65747
66145
  return (smallestDuration / duration) * (1 / (phi * minDurationWidth));
@@ -65804,6 +66202,18 @@
65804
66202
  canWrap = true;
65805
66203
  masterBar;
65806
66204
  additionalMultiBarRestIndexes = null;
66205
+ /**
66206
+ * Max fixed overhead (prefix + postfix glyph width) across all staves of this bar.
66207
+ * Used by the layout-mode horizontal scaling pass to carve out the fixed-overhead bucket
66208
+ * before distributing staff width across bars.
66209
+ */
66210
+ maxFixedOverhead = 0;
66211
+ /**
66212
+ * Max natural content width (computedWidth - fixedOverhead) across all staves of this bar.
66213
+ * Used as the bar weight when the layout ignores {@link MasterBar.displayScale} (e.g.
66214
+ * Page layout with `SystemsLayoutMode.Automatic`).
66215
+ */
66216
+ maxContentWidth = 0;
65807
66217
  get lastMasterBarIndex() {
65808
66218
  if (this.additionalMultiBarRestIndexes) {
65809
66219
  return this.additionalMultiBarRestIndexes[this.additionalMultiBarRestIndexes.length - 1];
@@ -65985,6 +66395,45 @@
65985
66395
  * This value is mainly used in the parchment style layout for correct scaling of the bars.
65986
66396
  */
65987
66397
  totalBarDisplayScale = 0;
66398
+ /**
66399
+ * Sum of per-bar {@link MasterBarsRenderers.maxFixedOverhead} across the system. The layout-mode
66400
+ * horizontal scaling pass subtracts this from the available staff width before distributing the
66401
+ * remainder across bars.
66402
+ */
66403
+ totalFixedOverhead = 0;
66404
+ /**
66405
+ * Sum of per-bar {@link MasterBarsRenderers.maxContentWidth} across the system. Used as the
66406
+ * denominator when distributing staff width in modes that weight bars by natural content width
66407
+ * (Page layout with `SystemsLayoutMode.Automatic`).
66408
+ */
66409
+ totalContentWidth = 0;
66410
+ /**
66411
+ * Shortest note duration (in ticks) across every bar that has been added to this system, used
66412
+ * as the common reference in the Gourlay stretch formula so that rhythmically-equivalent beats
66413
+ * in different bars of the same system align column-wise.
66414
+ *
66415
+ * `-1` means "no bar added yet". The value only moves downward during system assembly; when a
66416
+ * new bar introduces a shorter minimum, {@link isMinDurationDirty} is set so that
66417
+ * {@link reconcileMinDurationIfDirty} can re-derive spring constants on the previously-added
66418
+ * bars before layout distribution runs.
66419
+ */
66420
+ minDuration = -1;
66421
+ /**
66422
+ * Set when a bar added to this system introduced a shorter {@link minDuration} than previously
66423
+ * seen, leaving earlier bars' spring constants stale. Consumed by
66424
+ * {@link reconcileMinDurationIfDirty} which is called from `VerticalLayoutBase._fitSystem`
66425
+ * once the system is fully assembled.
66426
+ */
66427
+ isMinDurationDirty = false;
66428
+ /**
66429
+ * Whether this system coordinates a shared minimum-duration reference across its bars for the
66430
+ * Gourlay stretch formula. Defaults to `true` for page-style and parchment layouts where bars
66431
+ * of a system fight for a common staff width. Set to `false` for horizontal layouts where each
66432
+ * bar is sized independently (by `bar.displayWidth` or its intrinsic width) and there is no
66433
+ * column-alignment concern - each bar keeps its local minimum so pre-existing rendering is
66434
+ * preserved.
66435
+ */
66436
+ shareMinDurationAcrossBars = true;
65988
66437
  isLast = false;
65989
66438
  masterBarsRenderers = [];
65990
66439
  staves = [];
@@ -66046,6 +66495,9 @@
66046
66495
  }
66047
66496
  this.firstVisibleStaff = firstVisibleStaff;
66048
66497
  this._calculateAccoladeSpacing(tracks);
66498
+ // On the resize path the layoutingInfo was finalized in a previous layout pass, so we
66499
+ // only need to check whether its min-duration reference still matches the new system's.
66500
+ this._trackSystemMinDuration(renderers.layoutingInfo);
66049
66501
  this._applyLayoutAndUpdateWidth();
66050
66502
  return renderers;
66051
66503
  }
@@ -66101,10 +66553,89 @@
66101
66553
  this.firstVisibleStaff = firstVisibleStaff;
66102
66554
  this._calculateAccoladeSpacing(tracks);
66103
66555
  barLayoutingInfo.finish();
66556
+ // Reconcile against the system-wide minimum-duration reference now that springs are
66557
+ // finalized. If this bar introduced a shorter note, earlier bars become stale (flagged
66558
+ // for bulk reconcile at fit time). If the system already had a shorter min than this
66559
+ // bar's local one, this bar's spring constants are recomputed immediately so the width
66560
+ // we return below reflects the shared reference.
66561
+ this._trackSystemMinDuration(barLayoutingInfo);
66104
66562
  // ensure same widths of new renderer
66105
66563
  result.width = this._applyLayoutAndUpdateWidth();
66106
66564
  return result;
66107
66565
  }
66566
+ /**
66567
+ * Updates {@link minDuration} and {@link isMinDurationDirty} when a bar is added, and brings
66568
+ * the just-added bar's {@link BarLayoutingInfo} in line with the current system minimum if the
66569
+ * system already saw a shorter reference. The bulk reconcile over previously-added bars is
66570
+ * deferred to {@link reconcileMinDurationIfDirty} (called from `_fitSystem`) to avoid
66571
+ * re-iterating the system every time a bar is appended.
66572
+ */
66573
+ _trackSystemMinDuration(info) {
66574
+ if (!this.shareMinDurationAcrossBars) {
66575
+ return;
66576
+ }
66577
+ const localMin = info.localMinDuration;
66578
+ if (this.minDuration === -1 || localMin < this.minDuration) {
66579
+ // this bar shortens the system minimum; earlier bars (if any) are now stale
66580
+ if (this.masterBarsRenderers.length > 1 && localMin !== this.minDuration) {
66581
+ this.isMinDurationDirty = true;
66582
+ }
66583
+ this.minDuration = localMin;
66584
+ }
66585
+ if (info.computedWithMinDuration > this.minDuration) {
66586
+ // this bar was initialized against a larger (local) min than the system carries; pull
66587
+ // it down to the system reference so its computedWidth reflects the shared spacing.
66588
+ info.recomputeSpringConstants(this.minDuration);
66589
+ }
66590
+ }
66591
+ /**
66592
+ * Re-derives spring constants on bars whose {@link BarLayoutingInfo.computedWithMinDuration}
66593
+ * is out of sync with the current {@link minDuration}, and rebuilds the cached system totals
66594
+ * (widths, {@link totalFixedOverhead}, {@link totalContentWidth}) from the refreshed bar
66595
+ * widths. Called from `VerticalLayoutBase._fitSystem` after the system is fully assembled and
66596
+ * before distribution runs. No-op when {@link isMinDurationDirty} is false.
66597
+ */
66598
+ reconcileMinDurationIfDirty() {
66599
+ if (!this.isMinDurationDirty) {
66600
+ return;
66601
+ }
66602
+ let systemWidth = this.accoladeWidth;
66603
+ let totalFixedOverhead = 0;
66604
+ let totalContentWidth = 0;
66605
+ for (const mb of this.masterBarsRenderers) {
66606
+ if (mb.layoutingInfo.computedWithMinDuration > this.minDuration) {
66607
+ mb.layoutingInfo.recomputeSpringConstants(this.minDuration);
66608
+ }
66609
+ let maxPrefix = 0;
66610
+ let maxContent = 0;
66611
+ let realWidth = 0;
66612
+ for (const r of mb.renderers) {
66613
+ r.applyLayoutingInfo();
66614
+ if (r.computedWidth > realWidth) {
66615
+ realWidth = r.computedWidth;
66616
+ }
66617
+ const overhead = r.fixedOverhead;
66618
+ if (overhead > maxPrefix) {
66619
+ maxPrefix = overhead;
66620
+ }
66621
+ const content = Math.max(0, r.computedWidth - overhead);
66622
+ if (content > maxContent) {
66623
+ maxContent = content;
66624
+ }
66625
+ }
66626
+ mb.maxFixedOverhead = maxPrefix;
66627
+ mb.maxContentWidth = maxContent;
66628
+ mb.width = realWidth;
66629
+ systemWidth += realWidth;
66630
+ totalFixedOverhead += maxPrefix;
66631
+ totalContentWidth += maxContent;
66632
+ }
66633
+ this.width = systemWidth;
66634
+ this.computedWidth = systemWidth;
66635
+ this.totalFixedOverhead = totalFixedOverhead;
66636
+ this.totalContentWidth = totalContentWidth;
66637
+ this.isMinDurationDirty = false;
66638
+ }
66108
66639
  getBarDisplayScale(renderer) {
66109
66640
  return this.staves.length > 1 ? renderer.bar.masterBar.displayScale : renderer.bar.displayScale;
66110
66641
  }
@@ -66143,12 +66674,16 @@
66143
66674
  this.width -= width;
66144
66675
  this.computedWidth -= width;
66145
66676
  this.totalBarDisplayScale -= barDisplayScale;
66677
+ this.totalFixedOverhead -= toRemove.maxFixedOverhead;
66678
+ this.totalContentWidth -= toRemove.maxContentWidth;
66146
66679
  return toRemove;
66147
66680
  }
66148
66681
  return null;
66149
66682
  }
66150
66683
  _applyLayoutAndUpdateWidth() {
66151
66684
  let realWidth = 0;
66685
+ let maxFixedOverhead = 0;
66686
+ let maxContentWidth = 0;
66152
66687
  let barDisplayScale = 0;
66153
66688
  for (const s of this.allStaves) {
66154
66689
  const last = s.barRenderers[s.barRenderers.length - 1];
@@ -66157,8 +66692,21 @@
66157
66692
  if (last.computedWidth > realWidth) {
66158
66693
  realWidth = last.computedWidth;
66159
66694
  }
66695
+ const overhead = last.fixedOverhead;
66696
+ if (overhead > maxFixedOverhead) {
66697
+ maxFixedOverhead = overhead;
66698
+ }
66699
+ const content = Math.max(0, last.computedWidth - overhead);
66700
+ if (content > maxContentWidth) {
66701
+ maxContentWidth = content;
66702
+ }
66160
66703
  }
66704
+ const renderers = this.masterBarsRenderers[this.masterBarsRenderers.length - 1];
66705
+ renderers.maxFixedOverhead = maxFixedOverhead;
66706
+ renderers.maxContentWidth = maxContentWidth;
66161
66707
  this.totalBarDisplayScale += barDisplayScale;
66708
+ this.totalFixedOverhead += maxFixedOverhead;
66709
+ this.totalContentWidth += maxContentWidth;
66162
66710
  this.width += realWidth;
66163
66711
  this.computedWidth += realWidth;
66164
66712
  return realWidth;
@@ -66656,17 +67204,6 @@
66656
67204
  }
66657
67205
  }
66658
67206
 
66659
- /**
66660
- * @internal
66661
- */
66662
- class LazyPartial {
66663
- args;
66664
- renderCallback;
66665
- constructor(args, renderCallback) {
66666
- this.args = args;
66667
- this.renderCallback = renderCallback;
66668
- }
66669
- }
66670
67207
  /**
66671
67208
  * This is the base class for creating new layouting engines for the score renderer.
66672
67209
  * @internal
@@ -66697,15 +67234,21 @@
66697
67234
  this.doResize();
66698
67235
  }
66699
67236
  layoutAndRender(renderHints) {
66700
- this._lazyPartials.clear();
66701
67237
  this.slurRegistry.clear();
66702
- this.beamingRuleLookups.clear();
66703
- this._barRendererLookup.clear();
66704
- this.profile = Environment.staveProfiles.get(this.renderer.settings.display.staveProfile);
66705
67238
  const score = this.renderer.score;
66706
67239
  this.firstBarIndex = ModelUtils.computeFirstDisplayedBarIndex(score, this.renderer.settings);
66707
67240
  this.lastBarIndex = ModelUtils.computeLastDisplayedBarIndex(score, this.renderer.settings, this.firstBarIndex);
66708
67241
  this.multiBarRestInfo = ModelUtils.buildMultiBarRestInfo(this.renderer.tracks, this.firstBarIndex, this.lastBarIndex);
67242
+ const firstChangedMasterBar = renderHints?.firstChangedMasterBar;
67243
+ if (firstChangedMasterBar !== undefined) {
67244
+ if (this.doUpdateForBars(renderHints)) {
67245
+ return;
67246
+ }
67247
+ }
67248
+ this._lazyPartials.clear();
67249
+ this.beamingRuleLookups.clear();
67250
+ this._barRendererLookup.clear();
67251
+ this.profile = Environment.staveProfiles.get(this.renderer.settings.display.staveProfile);
66709
67252
  this.pagePadding = this.renderer.settings.display.padding.map(p => p / this.renderer.settings.display.scale);
66710
67253
  if (!this.pagePadding) {
66711
67254
  this.pagePadding = [0, 0, 0, 0];
@@ -66720,6 +67263,9 @@
66720
67263
  this.doLayoutAndRender(renderHints);
66721
67264
  }
66722
67265
  _lazyPartials = new Map();
67266
+ getExistingPartialArgs(id) {
67267
+ return this._lazyPartials.has(id) ? this._lazyPartials.get(id).args : undefined;
67268
+ }
66723
67269
  registerPartial(args, callback) {
66724
67270
  if (args.height === 0) {
66725
67271
  return;
@@ -66738,7 +67284,11 @@
66738
67284
  }
66739
67285
  else {
66740
67286
  // in case of lazy loading -> first register lazy, then notify
66741
- this._lazyPartials.set(args.id, new LazyPartial(args, callback));
67287
+ const partial = {
67288
+ args,
67289
+ renderCallback: callback
67290
+ };
67291
+ this._lazyPartials.set(args.id, partial);
66742
67292
  this.renderer.partialLayoutFinished.trigger(args);
66743
67293
  }
66744
67294
  }
@@ -67033,7 +67583,7 @@
67033
67583
  glyph.textAlign = TextAlign.Left;
67034
67584
  }
67035
67585
  }
67036
- layoutAndRenderAnnotation(y) {
67586
+ _layoutAndRenderAnnotation(y) {
67037
67587
  // attention, you are not allowed to remove change this notice within any version of this library without permission!
67038
67588
  const msg = 'rendered by alphaTab';
67039
67589
  const resources = this.renderer.settings.display.resources;
@@ -67097,6 +67647,12 @@
67097
67647
  }
67098
67648
  doResize() {
67099
67649
  }
67650
+ doUpdateForBars(_renderHints) {
67651
+ // not supported yet, modifications likely cause anyhow full updates
67652
+ // as we do not optimize effect bands yet. with effect bands being more
67653
+ // isolated in bars we could try updating dynamically
67654
+ return false;
67655
+ }
67100
67656
  doLayoutAndRender(renderHints) {
67101
67657
  const score = this.renderer.score;
67102
67658
  let startIndex = this.renderer.settings.display.startBar;
@@ -67110,6 +67666,11 @@
67110
67666
  endBarIndex = startIndex + endBarIndex - 1; // map count to array index
67111
67667
  endBarIndex = Math.min(score.masterBars.length - 1, Math.max(0, endBarIndex));
67112
67668
  this._system = this.createEmptyStaffSystem(0);
67669
+ // Each bar in horizontal layout is sized independently (by bar.displayWidth or the bar's
67670
+ // intrinsic width), so there is no shared staff width to distribute across bars. Keep each
67671
+ // bar's spring constants referenced against its own local minimum-duration so rendering
67672
+ // matches the historical per-bar behaviour.
67673
+ this._system.shareMinDurationAcrossBars = false;
67113
67674
  this._system.isLast = true;
67114
67675
  this._system.x = this.pagePadding[0];
67115
67676
  this._system.y = this.pagePadding[1];
@@ -67171,7 +67732,7 @@
67171
67732
  currentBarIndex += partial.masterBars.length;
67172
67733
  }
67173
67734
  this.height = this.layoutAndRenderBottomScoreInfo(this.height);
67174
- this.height = this.layoutAndRenderAnnotation(this.height);
67735
+ this.height = this._layoutAndRenderAnnotation(this.height);
67175
67736
  this.height += this.pagePadding[3];
67176
67737
  this.height *= this.renderer.settings.display.scale;
67177
67738
  }
@@ -67238,11 +67799,16 @@
67238
67799
  _allMasterBarRenderers = [];
67239
67800
  _barsFromPreviousSystem = [];
67240
67801
  _reuseViewPort = false;
67802
+ _preSystemPartialIds = [];
67803
+ _systemPartialIds = [];
67241
67804
  doLayoutAndRender(renderHints) {
67242
67805
  let y = this.pagePadding[1];
67243
67806
  this.width = this.renderer.width;
67244
67807
  this._allMasterBarRenderers = [];
67808
+ this._preSystemPartialIds = [];
67809
+ this._systemPartialIds = [];
67245
67810
  this._reuseViewPort = renderHints?.reuseViewport ?? false;
67811
+ this._systems = [];
67246
67812
  //
67247
67813
  // 1. Score Info
67248
67814
  y = this._layoutAndRenderScoreInfo(y, -1);
@@ -67254,15 +67820,23 @@
67254
67820
  y = this._layoutAndRenderChordDiagrams(y, -1);
67255
67821
  //
67256
67822
  // 4. One result per StaffSystem
67257
- y = this._layoutAndRenderScore(y);
67823
+ y = this._layoutAndRenderScore(y, this.firstBarIndex);
67258
67824
  y = this.layoutAndRenderBottomScoreInfo(y);
67259
- y = this.layoutAndRenderAnnotation(y);
67825
+ y = this._layoutAndRenderAnnotation(y);
67260
67826
  this.height = (y + this.pagePadding[3]) * this.renderer.settings.display.scale;
67261
67827
  }
67262
67828
  registerPartial(args, callback) {
67263
67829
  args.reuseViewport = this._reuseViewPort;
67264
67830
  super.registerPartial(args, callback);
67265
67831
  }
67832
+ reregisterPartial(id) {
67833
+ const args = this.getExistingPartialArgs(id);
67834
+ if (!args) {
67835
+ return;
67836
+ }
67837
+ args.reuseViewport = this._reuseViewPort;
67838
+ this.renderer.partialLayoutFinished.trigger(args);
67839
+ }
67266
67840
  get supportsResize() {
67267
67841
  return true;
67268
67842
  }
@@ -67273,6 +67847,47 @@
67273
67847
  }
67274
67848
  return x;
67275
67849
  }
67850
+ doUpdateForBars(renderHints) {
67851
+ this._reuseViewPort = renderHints.reuseViewport ?? false;
67852
+ const firstModifiedMasterBar = renderHints.firstChangedMasterBar;
67853
+ // first update existing systems as needed
67854
+ const systemIndex = this._systems.findIndex(s => {
67855
+ const first = s.masterBarsRenderers[0].masterBar.index;
67856
+ const last = s.masterBarsRenderers[s.masterBarsRenderers.length - 1].masterBar.index;
67857
+ return first <= firstModifiedMasterBar && firstModifiedMasterBar <= last;
67858
+ });
67859
+ if (systemIndex === -1 || !this.renderer.settings.core.enableLazyLoading) {
67860
+ return false;
67861
+ }
67862
+ // Bars from the start of the re-layouted system onward will be re-registered during the
67863
+ // paint pass. Clear their old entries from the preserved BoundsLookup so registration
67864
+ // produces a clean, complete lookup after this render finishes.
67865
+ const firstRebuiltBarIndex = this._systems[systemIndex].masterBarsRenderers[0].masterBar.index;
67866
+ this.renderer.boundsLookup.clearFromMasterBar(firstRebuiltBarIndex);
67867
+ // for now we do a full relayout from the first modified masterbar
67868
+ // there is a lot of room for even more performant updates, but they come
67869
+ // at a risk that features break.
67870
+ // e.g. we could only shift systems where the content didn't change,
67871
+ // but we might still have ties/slurs which have to be updated.
67872
+ const removeSystems = this._systems.splice(systemIndex, this._systems.length - systemIndex);
67873
+ this._systemPartialIds.splice(systemIndex, this._systemPartialIds.length - systemIndex);
67874
+ const system = removeSystems[0];
67875
+ let y = system.y;
67876
+ const firstBarIndex = system.masterBarsRenderers[0].masterBar.index;
67877
+ // signal all partials which didn't change
67878
+ for (const preSystemPartial of this._preSystemPartialIds) {
67879
+ this.reregisterPartial(preSystemPartial);
67880
+ }
67881
+ for (let i = 0; i < systemIndex; i++) {
67882
+ this.reregisterPartial(this._systemPartialIds[i]);
67883
+ }
67884
+ // new partials for all other prats
67885
+ y = this._layoutAndRenderScore(y, firstBarIndex);
67886
+ y = this.layoutAndRenderBottomScoreInfo(y);
67887
+ y = this._layoutAndRenderAnnotation(y);
67888
+ this.height = (y + this.pagePadding[3]) * this.renderer.settings.display.scale;
67889
+ return true;
67890
+ }
67276
67891
  doResize() {
67277
67892
  let y = this.pagePadding[1];
67278
67893
  this.width = this.renderer.width;
@@ -67291,7 +67906,7 @@
67291
67906
  // 4. One result per StaffSystem
67292
67907
  y = this._resizeAndRenderScore(y, oldHeight);
67293
67908
  y = this.layoutAndRenderBottomScoreInfo(y);
67294
- y = this.layoutAndRenderAnnotation(y);
67909
+ y = this._layoutAndRenderAnnotation(y);
67295
67910
  this.height = (y + this.pagePadding[3]) * this.renderer.settings.display.scale;
67296
67911
  }
67297
67912
  _layoutAndRenderTunings(y, totalHeight = -1) {
@@ -67315,6 +67930,7 @@
67315
67930
  canvas.textAlign = TextAlign.Center;
67316
67931
  this.tuningGlyph.paint(0, 0, canvas);
67317
67932
  });
67933
+ this._preSystemPartialIds.push(e.id);
67318
67934
  return y + tuningHeight;
67319
67935
  }
67320
67936
  _layoutAndRenderChordDiagrams(y, totalHeight = -1) {
@@ -67338,6 +67954,7 @@
67338
67954
  canvas.textAlign = TextAlign.Center;
67339
67955
  this.chordDiagrams.paint(0, 0, canvas);
67340
67956
  });
67957
+ this._preSystemPartialIds.push(e.id);
67341
67958
  return y + diagramHeight;
67342
67959
  }
67343
67960
  _layoutAndRenderScoreInfo(y, totalHeight = -1) {
@@ -67380,12 +67997,14 @@
67380
67997
  g.paint(0, 0, canvas);
67381
67998
  }
67382
67999
  });
68000
+ this._preSystemPartialIds.push(e.id);
67383
68001
  }
67384
68002
  return y + infoHeight;
67385
68003
  }
67386
68004
  _resizeAndRenderScore(y, oldHeight) {
67387
68005
  // if we have a fixed number of bars per row, we only need to refit them.
67388
68006
  const barsPerRowActive = this.getBarsPerSystem(0) > 0;
68007
+ this._systemPartialIds = [];
67389
68008
  if (barsPerRowActive) {
67390
68009
  for (let i = 0; i < this._systems.length; i++) {
67391
68010
  const system = this._systems[i];
@@ -67448,11 +68067,9 @@
67448
68067
  }
67449
68068
  return y;
67450
68069
  }
67451
- _layoutAndRenderScore(y) {
67452
- const startIndex = this.firstBarIndex;
68070
+ _layoutAndRenderScore(y, startIndex) {
67453
68071
  let currentBarIndex = startIndex;
67454
68072
  const endBarIndex = this.lastBarIndex;
67455
- this._systems = [];
67456
68073
  while (currentBarIndex <= endBarIndex) {
67457
68074
  // create system and align set proper coordinates
67458
68075
  const system = this._createStaffSystem(currentBarIndex, endBarIndex);
@@ -67487,6 +68104,7 @@
67487
68104
  // since we use partial drawing
67488
68105
  system.paint(0, -(args.y / this.renderer.settings.display.scale), canvas);
67489
68106
  });
68107
+ this._systemPartialIds.push(args.id);
67490
68108
  // calculate coordinates for next system
67491
68109
  return height;
67492
68110
  }
@@ -67494,6 +68112,10 @@
67494
68112
  * Realignes the bars in this line according to the available space
67495
68113
  */
67496
68114
  _fitSystem(system) {
68115
+ // If a bar added late in the assembly introduced a shorter note than earlier bars, the
68116
+ // earlier bars' spring constants (and the cached system widths / totals) are stale.
68117
+ // Reconcile now - it's a no-op when nothing changed.
68118
+ system.reconcileMinDurationIfDirty();
67497
68119
  if (system.isFull || system.width > this._maxWidth || this.renderer.settings.display.justifyLastSystem) {
67498
68120
  this._scaleToWidth(system, this._maxWidth);
67499
68121
  }
@@ -67505,29 +68127,35 @@
67505
68127
  _scaleToWidth(system, width) {
67506
68128
  const staffWidth = width - system.accoladeWidth;
67507
68129
  const shouldApplyBarScale = this.shouldApplyBarScale;
67508
- const totalScale = system.totalBarDisplayScale;
67509
- // NOTE: it currently delivers best results if we evenly distribute the available space across bars
67510
- // scaling bars relatively to their computed width, rather causes distortions whenever bars have
67511
- // pre-beat glyphs.
67512
- // most precise scaling would come if we use the contents (voiceContainerGlyph) width as a calculation
67513
- // factor. but this would make the calculation additionally complex with not much gain.
67514
- const difference = width - system.computedWidth;
67515
- const spacePerBar = difference / system.masterBarsRenderers.length;
68130
+ // Industry fixed-overhead model (Behind Bars, Dorico, Finale, Sibelius, MuseScore, Guitar Pro):
68131
+ // prefix/postfix glyphs (clef, key sig, time sig, barlines) are treated as fixed overhead and the
68132
+ // remaining staff width is distributed across bars by a per-bar weight.
68133
+ //
68134
+ // distributable = staffWidth - totalFixedOverhead
68135
+ // contentShare = distributable / sum(weight)
68136
+ // bar.width = bar.maxFixedOverhead + weight * contentShare
68137
+ //
68138
+ // The weight depends on the layout mode:
68139
+ // - shouldApplyBarScale=true -> weight = bar.displayScale (model-driven, matches Guitar Pro)
68140
+ // displayScale defaults to 1, so an unset value behaves identically
68141
+ // to an explicit 1 (GP omits the property when not customized).
68142
+ // - shouldApplyBarScale=false -> weight = natural content width (automatic, ignores displayScale)
68143
+ //
68144
+ // Per-bar maxFixedOverhead / maxContentWidth and the system-wide totals are maintained incrementally
68145
+ // in StaffSystem._applyLayoutAndUpdateWidth / revertLastBar so this pass can apply directly.
68146
+ const weightTotal = shouldApplyBarScale ? system.totalBarDisplayScale : system.totalContentWidth;
68147
+ const distributable = Math.max(0, staffWidth - system.totalFixedOverhead);
68148
+ const contentShare = weightTotal > 0 ? distributable / weightTotal : 0;
67516
68149
  for (const s of system.allStaves) {
67517
68150
  s.resetSharedLayoutData();
67518
- // scale the bars by keeping their respective ratio size
67519
68151
  let w = 0;
67520
- for (const renderer of s.barRenderers) {
68152
+ for (let i = 0; i < s.barRenderers.length; i++) {
68153
+ const renderer = s.barRenderers[i];
68154
+ const mb = system.masterBarsRenderers[i];
67521
68155
  renderer.x = w;
67522
68156
  renderer.y = s.topPadding + s.topOverflow;
67523
- let actualBarWidth;
67524
- if (shouldApplyBarScale) {
67525
- const barDisplayScale = system.getBarDisplayScale(renderer);
67526
- actualBarWidth = (barDisplayScale * staffWidth) / totalScale;
67527
- }
67528
- else {
67529
- actualBarWidth = renderer.computedWidth + spacePerBar;
67530
- }
68157
+ const weight = shouldApplyBarScale ? system.getBarDisplayScale(renderer) : mb.maxContentWidth;
68158
+ const actualBarWidth = mb.maxFixedOverhead + weight * contentShare;
67531
68159
  renderer.scaleToWidth(actualBarWidth);
67532
68160
  w += renderer.width;
67533
68161
  }
@@ -68551,11 +69179,43 @@
68551
69179
  }
68552
69180
  }
68553
69181
 
69182
+ /**
69183
+ * Helpers for building `TieGlyphLabel` instances from model-side
69184
+ * {@link SlurSegment}s.
69185
+ * @internal
69186
+ */
69187
+ class TieGlyphLabels {
69188
+ /**
69189
+ * Builds a `TieGlyphLabel` for one segment of a slur. The
69190
+ * `isAscending` flag selects between the H/P glyph for hammer-on
69191
+ * vs. pull-off — score side passes a comparison on `realValue`,
69192
+ * tab side passes a comparison on `fret`.
69193
+ */
69194
+ static build(s, isAscending) {
69195
+ if (s.kind === SlurSegmentKind.LegatoSlide) {
69196
+ return {
69197
+ fromNote: s.fromNote,
69198
+ toNote: s.toNote,
69199
+ text: s.text !== null ? s.text : 'sl.',
69200
+ element: exports.NotationElement.EffectSlideText
69201
+ };
69202
+ }
69203
+ // HammerPull
69204
+ return {
69205
+ fromNote: s.fromNote,
69206
+ toNote: s.toNote,
69207
+ text: s.text !== null ? s.text : isAscending ? 'H' : 'P',
69208
+ element: exports.NotationElement.EffectHammerOnPullOffText
69209
+ };
69210
+ }
69211
+ }
69212
+
68554
69213
  /**
68555
69214
  * @internal
68556
69215
  */
68557
69216
  class TabSlurGlyph extends TabTieGlyph {
68558
69217
  _forSlide;
69218
+ _labels = null;
68559
69219
  constructor(slurEffectId, startNote, endNote, forSlide, forEnd) {
68560
69220
  super(slurEffectId, startNote, endNote, forEnd);
68561
69221
  this._forSlide = forSlide;
@@ -68563,6 +69223,22 @@
68563
69223
  getTieHeight(startX, _startY, endX, _endY) {
68564
69224
  return (Math.log(endX - startX + 1) * this.renderer.settings.notation.slurHeight) / 2;
68565
69225
  }
69226
+ getSlurLabels() {
69227
+ if (this._labels === null) {
69228
+ this._labels = [];
69229
+ const slur = this.startNote.effectSlur;
69230
+ if (slur !== null) {
69231
+ const notationSettings = this.renderer.settings.notation;
69232
+ for (const s of slur.segments) {
69233
+ const label = TieGlyphLabels.build(s, s.toNote.fret >= s.fromNote.fret);
69234
+ if (notationSettings.isNotationElementVisible(label.element)) {
69235
+ this._labels.push(label);
69236
+ }
69237
+ }
69238
+ }
69239
+ }
69240
+ return this._labels.length > 0 ? this._labels : null;
69241
+ }
68566
69242
  tryExpand(startNote, endNote, forSlide, forEnd) {
68567
69243
  // same type required
68568
69244
  if (this._forSlide !== forSlide) {
@@ -68588,6 +69264,7 @@
68588
69264
  case BeamDirection.Up:
68589
69265
  if (startNote.realValue > this.startNote.realValue) {
68590
69266
  this.startNote = startNote;
69267
+ this._labels = null; // invalidate cache — labels live on startNote
68591
69268
  }
68592
69269
  if (endNote.realValue > this.endNote.realValue) {
68593
69270
  this.endNote = endNote;
@@ -68596,6 +69273,7 @@
68596
69273
  case BeamDirection.Down:
68597
69274
  if (startNote.realValue < this.startNote.realValue) {
68598
69275
  this.startNote = startNote;
69276
+ this._labels = null;
68599
69277
  }
68600
69278
  if (endNote.realValue < this.endNote.realValue) {
68601
69279
  this.endNote = endNote;
@@ -69351,12 +70029,24 @@
69351
70029
  const firstNonRestBeamingHelper = this.helpers.getBeamingHelperForBeat(firstNonRestBeat);
69352
70030
  const lastNonRestBeamingHelper = this.helpers.getBeamingHelperForBeat(lastNonRestBeat);
69353
70031
  const direction = this.getTupletBeamDirection(firstNonRestBeamingHelper);
69354
- let startY = this.calculateBeamYWithDirection(firstNonRestBeamingHelper, startX, direction);
69355
- let endY = this.calculateBeamYWithDirection(lastNonRestBeamingHelper, endX, direction);
70032
+ let startY;
70033
+ let endY;
69356
70034
  if (isRestOnly) {
69357
- startY = Math.max(startY, endY);
70035
+ // rests have no stems, so anchor to the actual rest glyph bounds
70036
+ // instead of a stem-adjusted flag position (which would place the bracket
70037
+ // a full quarter-stem length away from the rests).
70038
+ if (direction === BeamDirection.Up) {
70039
+ startY = Math.min(this.getRestY(firstNonRestBeat, NoteYPosition.Top), this.getRestY(lastNonRestBeat, NoteYPosition.Top));
70040
+ }
70041
+ else {
70042
+ startY = Math.max(this.getRestY(firstNonRestBeat, NoteYPosition.Bottom), this.getRestY(lastNonRestBeat, NoteYPosition.Bottom));
70043
+ }
69358
70044
  endY = startY;
69359
70045
  }
70046
+ else {
70047
+ startY = this.calculateBeamYWithDirection(firstNonRestBeamingHelper, startX, direction);
70048
+ endY = this.calculateBeamYWithDirection(lastNonRestBeamingHelper, endX, direction);
70049
+ }
69360
70050
  // align line centered in available space
69361
70051
  if (direction === BeamDirection.Down) {
69362
70052
  startY += shift;
@@ -69726,7 +70416,30 @@
69726
70416
  let minNoteY = 0;
69727
70417
  for (const v of this.helpers.beamHelpers) {
69728
70418
  for (const h of v) {
69729
- if (!this.shouldPaintBeamingHelper(h)) ;
70419
+ if (!this.shouldPaintBeamingHelper(h)) {
70420
+ // beam is not drawn, but a rest-only tuplet still draws a bracket
70421
+ // anchored to the rest glyph bounds and needs overflow reserved.
70422
+ if (h.hasTuplet && h.isRestBeamHelper) {
70423
+ const tupletGroup = h.beats[0].tupletGroup;
70424
+ const tupletFirst = tupletGroup.beats[0];
70425
+ const tupletLast = tupletGroup.beats[tupletGroup.beats.length - 1];
70426
+ const tupletDirection = this.getTupletBeamDirection(h);
70427
+ if (tupletDirection === BeamDirection.Up) {
70428
+ const restTop = Math.min(this.getRestY(tupletFirst, NoteYPosition.Top), this.getRestY(tupletLast, NoteYPosition.Top));
70429
+ const topY = restTop - this.tupletSize - this.tupletOffset;
70430
+ if (topY < maxNoteY) {
70431
+ maxNoteY = topY;
70432
+ }
70433
+ }
70434
+ else {
70435
+ const restBottom = Math.max(this.getRestY(tupletFirst, NoteYPosition.Bottom), this.getRestY(tupletLast, NoteYPosition.Bottom));
70436
+ const bottomY = restBottom + this.tupletSize + this.tupletOffset;
70437
+ if (bottomY > minNoteY) {
70438
+ minNoteY = bottomY;
70439
+ }
70440
+ }
70441
+ }
70442
+ }
69730
70443
  else if (h.beats.length === 1 && h.beats[0].duration >= Duration.Half) {
69731
70444
  const tupletDirection = this.getTupletBeamDirection(h);
69732
70445
  const direction = this.getBeamDirection(h);
@@ -73298,9 +74011,26 @@
73298
74011
  * @internal
73299
74012
  */
73300
74013
  class ScoreSlurGlyph extends ScoreTieGlyph {
74014
+ _labels = null;
73301
74015
  getTieHeight(startX, _startY, endX, _endY) {
73302
74016
  return (Math.log2(endX - startX + 1) * this.renderer.settings.notation.slurHeight) / 2;
73303
74017
  }
74018
+ getSlurLabels() {
74019
+ if (this._labels === null) {
74020
+ this._labels = [];
74021
+ const slur = this.startNote.beat.effectSlur;
74022
+ if (slur !== null) {
74023
+ const notationSettings = this.renderer.settings.notation;
74024
+ for (const s of slur.segments) {
74025
+ const label = TieGlyphLabels.build(s, s.toNote.realValue >= s.fromNote.realValue);
74026
+ if (notationSettings.isNotationElementVisible(label.element)) {
74027
+ this._labels.push(label);
74028
+ }
74029
+ }
74030
+ }
74031
+ }
74032
+ return this._labels.length > 0 ? this._labels : null;
74033
+ }
73304
74034
  calculateStartX() {
73305
74035
  return (this.renderer.x +
73306
74036
  (this._isStartCentered()