Skip to content

perf(rendering): rework the layout flow so resizes match fresh renders at half the time and memory - #2997

Merged
Danielku15 merged 32 commits into
developfrom
pr/layout-flow
Oct 31, 2026
Merged

Danielku15 merged 32 commits into
developfrom
pr/layout-flow

Conversation

@Danielku15

Copy link
Copy Markdown
Member

Note

AI-authored disclosure (alphatab-ai-authored-v1)

Portions of this content were authored by an AI agent. The agent has read
AGENTS.md and the human submitter accepts responsibility for
compliance with the rules in that document.

Issues

No dedicated issue: part of the 1.9 release preparation.

Proposed changes

Problem. A resize produced a different layout than a fresh render at the same width. Across 2,725 test
cases (109 scores × 5 variations × 5 widths), 40 % differed:

  • forced line breaks moved one bar early;
  • bar widths shifted by up to 0.5 px;
  • system heights and the final barline moved.

Layout and resize were also slow and allocation-heavy. On large scores, every step of a window drag allocated about 45 MB.

What changes. The page/parchment layout is now one flow:

  • each bar is measured once;
  • the system breaks are decided before bars are added;
  • systems are assembled once and reused on resize.

Around that flow, rendering, the browser worker hand-off, the player and the serializers were reworked to avoid
work and allocations nobody needs.

Most of the bug fixes below came out of the resize-vs-fresh comparison and the visual review of the new output.

Layout and resize

  • One rule decides which bars fit a system, for the initial layout, resizes and partial updates alike.
    Forced line breaks stay on their bar, and a resize gives the same result as a fresh render at that width.
    This holds in all 2,725 comparison cases (line breaks, bar widths, system heights, glyph positions and bounds).
  • Justified bar widths survive the finalize step, and overflows are re-derived per layout pass. Before, they only
    ever grew, so the result depended on the resize history.
  • Page and parchment spacing use one duration reference per layout instead of a per-system one. Bar widths no
    longer depend on the neighbouring bars of the system.
  • Resizes reuse systems, staves, skylines and effect placements. Glyph, beaming, skyline, tie and bounds data
    avoid per-layout allocations.
  • Fixed widths that changed between passes: whammy helper notes, bend helper note heads, and tab note geometry
    that changed when painted. The whammy and stale bend-overflow cases are also present on develop today.

Rendering output (reference images updated in the last commit)

  • Numbered notation: rests longer than a quarter repeat 0 (0 0, 0 0 0 0) instead of the dashes of held notes.
  • Colours: voice colours now reach stems, beams, flags, tuplets, effects, slur continuations and multi-note tab
    bends. null style entries reset score elements, track names and the header tuning to their own defaults. The
    system separator uses the main glyph colour by default. Bar styles without a staff-line entry use the default
    staff-line colour.
  • The bend labels of several bent notes on one beat are merged and stacked instead of drawn over each other.
  • Bend and whammy outlines claim less space (placements move by ≤ 0.8 px), and the empty space in front of bend
    helper accidentals is gone.
  • Staff lines are painted once per run of bars.
  • SVG output: no newlines between elements, black rects without a fill attribute, single music symbols end
    with ; (valid XML), and Kotlin writes symbol codes as integers.

Browser, worker and player

  • The worker sends the bounds lookup as a transferable Float64Array instead of a nested Map tree.
  • Scores go to the worker as one transferred MessagePack buffer instead of a structured-cloned object tree. Generated
    numeric field ids keep it at 1.6 MB for the dense test score; with string keys it would be 8.7 MB. Errors while
    the worker reads a score now reach api.error.
  • Resizes are coalesced while one is in flight. Intermediate widths during a drag raise no render events; this is
    documented on IScoreRenderer.resizeRender.
  • Partials far out of view release their DOM and render again when they come back. Before, the whole rendered
    sheet stayed in memory after one scroll through it. Beat highlights survive that re-render.
  • The midi is generated once per score load and player change, instead of up to four times. With a worker
    renderer the score is posted for rendering before its midi is generated.
  • New setting player.positionUpdateIntervalInMilliseconds, default 10 ms: the audio worklet reports the position
    every 10 ms instead of every 128-frame quantum. 0 restores the previous behaviour.
  • destroy() releases the loaded model. Painted partials are no longer retained in the lazy-partial store.

Model, importers, platform and build

  • Guitar Pro 3–5: drum grace notes are read as percussion notes, the note duration percent is read little-endian
    (canon.gp5 and five other files had broken values), and notes without fingering get "unknown finger".
  • An alphaTex round trip keeps empty Guitar Pro voices, their dynamics and the GP7–8 drum note strings stable.
  • JsonConverter accepts null colours and fonts, reads fonts from plain JSON objects and writes typed arrays as
    JSON arrays.
  • The horizontal layout builds its bounds lookup once instead of once per partial; before, findBeats returned
    every beat once per partial. A partial render that falls back to a full layout no longer duplicates systems in
    the lookup.
  • New log level LogLevel.Trace (Logger.trace). Internal flow details moved from debug to trace. Trace calls are
    stripped from the library builds.
  • Library builds inline enum member reads across modules (−10 KB gzip on the minified core). keep_quoted makes
    Ottavia._8va and the other underscore members defined again in the minified bundles.
  • The webpack plugin no longer hangs the build when it cannot copy its assets; it reports the error instead.
  • Platform fixes: the IntersectionObserver polyfill now checks on scroll/resize, GDI/Android skip zero-width rects,
    Kotlin List.splice returns only the removed elements, Android map/set deletes compare keys, and .NET/Android
    parse numbers like JavaScript parseInt (via the platform parsers).

Measured result (dense test score, 216 bars × 20 staves, all tracks)

develop this PR change
Initial render (Node, svg: layout + paint) 200 ms 105 ms −47 %
Resize 1300 → 800 px / 800 → 1300 px 63 / 55 ms 24 / 21 ms −63 % / −60 %
Allocated per initial render 233 MB 114 MB −51 %
Allocated per resize 58 / 48 MB 19 / 12 MB −67 % / −74 %
Heap kept by a finished renderer 152 MB 53 MB −65 %
Chromium renderer memory after scrolling through the score (PSS) 410 MB 256 MB −38 %
Worker heap in Chromium 81–91 MB 40–44 MB −51…54 %
Time until visible partials are ready, Chromium (load / resize) 777 / 243 ms 541 / 108 ms −30 % / −56 %
alphaTab.core.min.mjs gzip 296.2 KiB 303.3 KiB +2.4 %

The other test scores (canon, full-song, notation-legend, a GP7 file) show the same direction. On all of them,
initial render takes 33–48 % less time and resizes 47–63 % less.

Compared with 1.8.4, the release is faster as well: initial render −28…36 % and resize −13…39 % on real
songs, and allocations are about halved. The one exception is the small notation-legend score, where
both are on par.

Compatibility notes

  • Rendering output changes as listed above. The reference images are updated in one commit at the end.
  • Main and worker builds must be the same version. The worker message format changed (MessagePack score,
    flat bounds).
  • SVG text output differs in whitespace and attributes (see above). It renders the same.

Checklist

  • I consent that this change becomes part of alphaTab under its current or any future open source license
  • This PR is linked to an accepted issue (see above: release preparation, no dedicated issue)
  • Changes are implemented
  • New tests were added
    • unit tests for every fix;
    • visual tests for numbered rests, colours and bend labels;
    • the bounds wire format checked against toJson/fromJson over the visual-test corpus.
    • The resize-vs-fresh comparison of the 2,725 cases was a separate harness during development, not part of this PR.
    • Last full run: TS 2374/2374, C# 2204/2204, Kotlin 2204/2204.
  • I have read AGENTS.md if an AI helped draft any part of this PR

AI authorship disclosure

  • No AI agent authored any part of this PR (description, code, tests, or commit messages)
  • An AI agent contributed to this PR. The AI-authored disclosure block
    (alphatab-ai-authored-v1) is present at the top of this body, and I have
    personally reviewed every change and can explain each one

Further details

  • This is a breaking change
  • This change will require update of the documentation/website

…duration reference per layout

Adds the layout-flow harness (resize-vs-fresh oracle, benchmarks, allocation and CPU probes) and fixes the first causes of resize results that differ from a fresh render.

- test(rendering): add layout-flow harness for resize equivalence and layout benchmarks
- test(rendering): compare layout-flow svg output below pixel precision
- test(rendering): spike header spacing cache key over the layout-flow corpus
- test(rendering): report header glyph allocations in the header cache spike
- fix(rendering): keep justified bar widths and reset overflows per layout pass
- test(rendering): add allocation, heap census, CPU and glyph visit analysis to the layout-flow harness
- refactor(rendering): space page and parchment bars against one duration reference per layout
- fix(rendering): include the beat content in the bottom extent of bent beats
- test(rendering): check header glyph geometry against the header cache key
- test(rendering): create the layout-flow output folder before writing heap snapshots
…out temporary allocations

- perf(rendering): create beat glyph collections on first use
- perf(rendering): create bar renderer helper collections on first use
- perf(rendering): avoid style scope allocations when painting unstyled elements
- perf(rendering): paint staff lines without collecting gap arrays
- perf(rendering): paint beam flags without creating flag glyphs
- perf(rendering): rotate tie control points without tuple allocations
- perf(rendering): compute child paint offsets once in hot glyph loops
- perf(rendering): store skyline segments in parallel number arrays
- test(rendering): keep the layout-flow stage classifier out of the C# and Kotlin test builds
…he bounds lookup with fewer allocations

- perf(rendering): lay out bar headers from shared profiles and create their glyphs for painting
- perf(rendering): resolve bounds lookups with a single map access
- perf(rendering): sort bar and beat bounds only when out of order
- perf(rendering): build voice bounds without map entry allocations
- perf(rendering): build the bounds lookup at display scale
One break rule for initial layout, resize and partial updates; forced line breaks stay on their bar after a resize.

- refactor(rendering): decide which bars fit a system before adding them
- perf(rendering): cache the vertical note extent of beat containers
- perf(rendering): measure the gradual bend label once per bend glyph
- perf(rendering): finish beaming helpers only once per bar layout
- test(rendering): probe the measure-and-attach layout stages and skip removed methods
…kyline allocations

- perf(rendering): skip bar-local overlay rods and overflows when attaching a bar
- perf(rendering): compute only the natural bar width when placing a bar
- test(rendering): measure the worker bounds hand-off and a flat wire prototype
- perf(rendering): index beat containers and beam helpers by voice and beat
- perf(rendering): create beaming helper storage on demand
- perf(rendering): allocate shared beat sizes only for new beat positions
- perf(rendering): find tab note glyphs by scanning the chord
- perf(rendering): keep reserved collision slots as number pairs
- perf(rendering): create bar-local skylines on the first insert
- perf(rendering): keep the note of each main note head in the chord note infos
- perf(rendering): look up chord note head infos per bar renderer without string keys
- perf(rendering): create note parentheses, fingering and accidental groups only when needed
- perf(rendering): keep a single glyph list per beat glyph
- perf(rendering): create the chord note info list with its first note head
- perf(rendering): share one effect placement scratch per layout
- perf(rendering): refill the systems of the previous pass on resize
- perf(rendering): keep the systems of earlier resizes for later ones
- perf(rendering): merge skylines only over the shifted window
- perf(rendering): keep on-time positions per spring index for two forces
- perf(rendering): collect slur continuations without a generator
- fix(rendering): build the slur registry staff key outside RenderStaff
- fix(rendering): pass every argument in the effect note head super call
- perf(rendering): resolve the effects that can create glyphs once per staff
- fix(rendering): keep the bar of bar bounds when transferring the bounds lookup
…flat buffer

Also fixes the SVG character references of music symbols, the bar of transferred bar bounds and the horizontal bounds lookup built once per partial.

- perf(svg): reuse the entity text of music font symbols
- test(layout-flow): probe the cost of the worker JSON hand-offs
- test(rendering): measure what lazy or compact bounds lookups could save
- test(layout-flow): staff-line run cost and image comparison probes
- fix(svg): terminate the character reference of single music symbols
- test(rendering): sort the imports of the bounds lookup JSON test
- fix(rendering): build the horizontal bounds lookup once instead of per partial
- perf(rendering): create the note bounds list with its first entry
- perf(rendering): hand the bounds lookup from the browser worker as a flat buffer
- test(layout-flow): measure the flat bounds hand-off in the worker probes
- test(rendering): sort the imports of the bounds wire test
- fix(svg): write music symbol codes as integers on Kotlin
- fix(rendering): use the default staff-line colour for bar styles without a staff-line entry
- perf(rendering): paint staff lines once per run of bars
- fix(platform): skip rectangles without width in the GDI and Android canvases
- test(rendering): cover staff-line colour runs, same-string tab holes and ties across partials
- test(layout-flow): measure the staff-line run painter and summarise staff-line SVG per commit
- test(rendering): update reference images for staff lines painted per run
…rate the midi once per score load

- perf(rendering): share the active effects of a staff across the systems of a layout
- fix(model): accept null colours when deserializing
- fix(rendering): reserve beam overflow of numbered rest-only beaming helpers
- test(layout-flow): surface layout errors in the harness and the oracle
- fix(player): generate the midi once per score load and player change
- fix(rendering): keep tab note geometry unchanged by painting
…bjects

- build(serializer): emit web-only plain-object toJsObject beside toJson
- build(serializer): regenerate serializers with toJsObject
- perf(worker): send score and settings to the render worker as plain objects
- test(model): compare the toJsObject serializers with the Map path
- test(rendering): write the numbered rest beaming test without generated test cases
- fix(worker): serialize the score through an instance method
- test(rendering): use test constructs that transpile to C# and Kotlin
- test(rendering): add a paint output probe (SVG census, buffer copy volume, string-building variants)
- test(layout-flow): probe for import, model, midi and synth costs
- test(rendering): retained-memory probe and byte-level heap snapshot census
- test(layout-flow): probe resize allocation, finalize call counts and geometry kept between widths
- test(rendering): add glyph census and measure-stage profile probes
- test(layout-flow): keep the paint output probe out of the C# and Kotlin builds
…t and synth allocations

- perf(rendering): skip beaming helpers and stem slots on tab staves with hidden rhythm
- perf(rendering): lay out note numbers once and cache text measurements per layout
- perf(rendering): lighter effect band containers
- fix(rendering): fill the effect band glyph slots without using the fill result
- perf(synth): update the voice low-pass state in place
- perf(core): release the loaded model on destroy
- perf(synth): hand the midi to the synth worker as a flat transferable buffer
- perf(rendering): stop retaining painted partials in the lazy-partial store
- perf(rendering): memoise SVG attribute tails and append once per element
- perf(rendering): build SVG output in a StringBuilder on C# and Kotlin
- perf(rendering): clear resize scratch arrays with pop loops instead of splice
- perf(rendering): sort effect bands with a static comparator and skip trivial sorts
- perf(rendering): clear the staff shared layout data only when it holds entries
- perf(rendering): initialise the skyline bounds fields
- perf(rendering): use indexed loops in the per-system resize functions
- perf(rendering): trim the staff system pool after a layout pass
…s far out of view

- perf(worker): coalesce resize renders while one is in flight
- perf(browser): release the DOM of partials far out of view
- perf(player): post the score for rendering before generating its midi
- perf(player): make the audio worklet position report interval configurable
…aner SVG

- perf(rendering): write the worker bounds directly in the wire format
- test(layout-flow): compare the bounds lookup in the resize oracle
- test(layout-flow): add a drag-like width sequence to the resize oracle
- perf(rendering): keep pooled systems assembled when a resize gives them the same bars
- perf(rendering): compact the chord note-head records
- perf(rendering): keep the main beat glyph only in its typed field
- perf(rendering): place pre-note spacing as numbers and drop empty pre-notes
- perf(rendering): paint a single regular barline without a child glyph
- perf(rendering): share the beaming and effect band scratch per layout
- perf(rendering): share staff effect band lists across systems and start glyph groups without a list
- perf(svg): write SVG output without newlines between elements
- perf(player): report the played position every 10 milliseconds by default
- perf(rendering): compute tie geometry in a layout scratch array and keep the tie bounding box as numbers
- perf(rendering): skip completed multi-system slurs when collecting continuations
- perf(rendering): return a shared display record from StaffDisplayResolver.merge when nothing overrides
- perf(rendering): store single beat bounds without a list in the bounds lookup
- test(rendering): pass typed display records in the staff display resolver tests
- fix(browser): keep beat highlights on partials rendered while highlighting
- perf(svg): write black rects without a fill attribute
Brings in the develop fixes up to 3984b32 (1.9.0 hardening, beaming bounds of
beats without notes, triplet feel/capo placement, MusicXML dynamics, synth and
importer fixes, dependency bumps).

Develop's behaviour fixes are ported into the reworked layout code (header
profiles, per-layout effect placement, staff lines painted per run, array-based
note head records, flat worker hand-offs). Reference images that both sides
changed are regenerated from the merged code; notation-legend
resize-sequence-500 follows develop's fresh layout, since develop's reference
records a stale helper-glyph overflow after resizing from a wider width.
…converter

- test(rendering): use transpilable constructs in the bar layouting info tests
- docs(transpiler): list Float64Array as unsupported
- test(rendering): name the fourth slur scale-down reference after its width
- fix(kotlin): return only the removed elements from List.splice
- fix(model): read fonts from plain JSON objects
- fix(model): accept a null font when deserializing
- fix(model): write typed arrays as JSON arrays in the JSON converter
- test(rendering): add width-dependence probe for the glyph layer
- fix(rendering): keep whammy bar helper notes stable across finalize passes
- fix(rendering): restart the bounds lookup when a partial render lays out everything
- test(layout-flow): compare the worker wire bounds in the resize oracle
- test(rendering): check the bounds wire format on a small covering score set
- fix(model): cast typed arrays through unknown for the C#/Kotlin type check
…one glyph list per effect band

- perf(synth): write the SynthHelper formulas out in Voice.render
- perf(rendering): use indexed loops in rarely optimised layout and paint functions
- perf(rendering): keep one glyph list per effect band and create chain heads on demand
… percent correctly

- fix(importer): read Guitar Pro 3-5 grace notes on drum tracks as percussion notes
- test(exporter): round trip the dense Guitar Pro 4 score through alphaTex
- fix(importer): read the Guitar Pro 5 note duration percent as little-endian double
- fix(platform): run the IntersectionObserver polyfill check on scroll and resize
- test(audio): make the backing track output tests independent of test order
…uilds

- perf(build): inline enum member reads across modules in the library builds
- perf(rendering): clear tie shapes, key overlay rods and keep ledger line trims without allocating
- perf(rendering): keep tie control points as doubles and sort effect bands in place
- fix(rendering): lay out bend helper note heads the same way on every call
- test(rendering): avoid a narrowed nullable local in the bend helper layout test
…ble on an alphaTex round trip

- fix(importer): keep empty Guitar Pro voices and their dynamics stable on an alphaTex round trip
- fix(importer): place Guitar Pro 7-8 drum notes on strings of their staff
- fix(importer): read Guitar Pro 3-5 notes without fingering on a hand as unknown finger
…nd outline bends with fewer skyline boxes

- fix(rendering): apply the default colour for style elements without an entry when forced
- test(rendering): cover whole rests on numbered staves visually
- fix(rendering): write rests longer than a quarter as repeated 0 on numbered staves
- perf(rendering): register bend and whammy outlines with fewer skyline boxes
- test(rendering): name the skyline shape test suite apart from the class it tests
- feat(core): add the trace log level
- fix(core): drop the stray null detail from debug log calls
- refactor(core): move internal flow details from debug to trace
- perf(player): build the position trace message only when trace is enabled
- perf(build): strip trace logging from the library builds
- test: restore the global log level after tests that turn logging off
… keep quoted names in minified bundles

- fix(android): compare keys when deleting map and set entries
- fix(cross): parse integers like JavaScript parseInt on .NET and Android
- fix(build): keep quoted property names in the minified bundles
- test(exporter): compare numbers set on one side only in the round-trip helper
…the voice style

- perf(rendering): paint ties into a scratch array of the layout
- perf(rendering): collect overlay rods with indexed loops
- fix(rendering): colour stems, beams, flags, tuplets and effects with the voice style
- fix(rendering): reset score elements to the score info colour on a null style entry
- fix(rendering): reset track names to their own default colour on a null style entry
- perf(rendering): split bend and whammy outlines into coarser skyline steps
- fix(rendering): drop the empty space in front of bend helper note accidentals
…ack through generated object readers and writers

- refactor(serializer): generate write and read methods against IObjectWriter and IObjectReader
- perf(serializer): keep closures out of the generated property readers
- feat(worker): optionally send the score to the rendering worker as BSON
- feat(worker): add MessagePack as a second binary score format
- refactor(serializer): build the JSON object trees with PlainObjectWriter
- perf(worker): send scores to the rendering worker as MessagePack by default
- refactor(worker): send scores to the rendering worker only as MessagePack
- fix(worker): leave headroom in the buffer for the next binary score
- test(layout-flow): probe the binary score contents and the settings sync per resize
- test: read serialized score arrays as untyped lists in the snapshot printer
… in their style colours

- fix(logging): drop the stray null detail from five warning and error messages
- fix(webpack): finish the build when the asset copy cannot run
- refactor(cross): parse numbers with the platform parsers on .NET and Android
- fix(rendering): paint the middle pieces of multi-system slurs in their voice colour
- fix(rendering): paint the system separator in the main glyph colour by default
- fix(rendering): reset the score header tuning to the score info colour
- fix(rendering): paint bends of several notes on the tab staff in the voice colour
- perf(worker): key the record fields of the binary score by generated ids
- fix(worker): report errors while reading the score through the error event
…he tab staff

- fix(rendering): merge and stack the bend labels of several notes on the tab staff
…and write fields with typed calls

- fix(android): grow boolean lists and search and sort number lists like JavaScript arrays
- refactor(serializer): keep the field keys of each class once, in a generated field table
- refactor(worker): decode the remaining string keys of the binary score without a key cache
- refactor(serializer): generate toJson/fromJson only for the classes tagged @json_root
- refactor(serializer): read the fields of strict classes in the handler of read
- perf(serializer): write each primitive, array and map field with one typed writer call
- test: drop the layout-flow harness
The images of all topic commits of this branch, committed once. They change for:

- fix(rendering): keep justified bar widths and space bars against one duration reference per layout
- refactor(rendering): decide which bars fit a system before adding them
- perf(rendering): paint staff lines once per run of bars
- fix(model): read fonts, null fonts and typed arrays through the JSON converter
- fix(rendering): write longer rests as repeated 0 on numbered staves and outline bends with fewer skyline boxes
- fix(rendering): colour stems, beams, flags, tuplets and effects with the voice style
- fix(rendering): paint slurs, system separators, tunings and tab bends in their style colours
- fix(rendering): merge and stack the bend labels of several notes on the tab staff
It served to verify the position update interval while it was developed and is not needed as a demo.
…writer

Biome 2.6 (develop's dependency update) reports the NaN check `v !== v` (noSelfCompare) and the
assignment inside the ASCII copy loop condition (noAssignInExpressions). Both are written out
without changing the output.
@Danielku15
Danielku15 merged commit 03c41f6 into develop Oct 31, 2026
5 checks passed
@Danielku15
Danielku15 deleted the pr/layout-flow branch October 31, 2026 00:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant