Swift port-in-progress of OpenSheetMusicDisplay (OSMD), using VexFoundation for rendering.
See AUTHORS and LICENSE for the original authors of the code.
When working on VexScoreView / MusicDisplayLazyScoreView, treat render data as a staged pipeline and cache only stable stages.
Use these cache boundaries:
Score + LayoutOptions -> LaidOutScoreLaidOutScore + RenderTarget -> VexRenderPlan- Lazy view only:
VexRenderPlan -> [PreparedLazySystemRender] + system index range + measure range by system(pre-sliced row plans and visibility metadata) - Lazy score + source
Score: apply a viewport-drivenmeasureRangewindow, and expand it as the visible systems approach the loaded range edge.
These values are deterministic for the same input and should be recomputed only when inputs change.
LaidOutScore now carries a renderRevision token. Use that revision
as the cache invalidation key for render-stage caches (VexRenderPlan,
lazy system slices) instead of deep LaidOutScore equality checks in
SwiftUI body.
VexFactoryExecution is single-use in practice. Factory.draw() drains and resets the factory queue, so reusing a cached execution can render blank content on subsequent redraws (for example, after scroll invalidation).
Draw-time rule:
- In each
VexCanvasdraw callback, create a fresh execution from a cached plan:executeRenderPlan(plan) -> drawExecution(execution, on: context)
Avoid doing any of the following directly in SwiftUI body evaluation:
- music layout
- render-plan construction
- per-system plan slicing
- font bootstrap/loading
Compute these in cache objects keyed by input value equality, then make body mostly a lightweight selection layer.
For lazy system rendering specifically:
- precompute each system row's
VexRenderPlanonce in cache (PreparedLazySystemRender) - precompute
availableSystemIndexRangeandmeasureRangeBySystem - avoid per-row fallback
systemSlicework insideForEach
For geometry-driven relayout (autoResize), debounce width commits to
avoid layout thrash while users resize/split views.
SwiftUI.Canvas in this stack is callback-based and does not provide a dirty-rect incremental redraw API through VexCanvas.
Use coarse-grained partial redraw instead:
- split long scores into per-system canvases (
MusicDisplayLazyScoreView) - rely on
LazyVStackmaterialization so only nearby rows are active - avoid hard row clipping when system bounds are tight; only clip if row bounds include glyph overflow margins
OSMD's VexFlowMeasure.resetLayout() creates staves with:
space_above_staff_ln = 0space_below_staff_ln = 0
Mirror this in Swift (StaveOptions(spaceAboveStaffLn: 0, spaceBelowStaffLn: 0)),
otherwise VexFoundation default inset values shift staff lines downward relative
to layout frames and can cause bottom-line clipping in lazy row slices.
OSMD renders mid-system clef changes as inline note modifiers (ClefNote
inside NoteSubGroup) attached to the target staff entry. Do the same in Swift:
- detect measure-transition clef changes in
makeRenderPlan - attach a small
ClefNoteviaNoteSubGroupduringexecuteRenderPlan
MusicDisplayKitModel now keeps timed in-measure clef events (Measure.clefEvents),
so clef changes can render both at measure start and mid-measure on the
first note at/after the change onset.
Current model caveat: key/time/transposition remain measure-scoped in
MeasureAttributes (no timed intra-measure events yet).
This is the supported way to avoid redrawing the entire score on scroll.
Use monotonic visibility signals (for example, highest visible system seen)
for window expansion to avoid scroll flicker from rapid
onAppear/onDisappear churn.
When MusicDisplayLazyScoreView is fed a precomputed LaidOutScore (not
raw Score), skip visibility-window bookkeeping entirely. There is no
measure-window expansion path in that mode, so updating visibility state on
every row appearance just adds invalidation churn.
If a host app overlays playback cursors or selection UI on top of a score,
keep the score surface in its own Equatable subview keyed by
laidOutScore.renderRevision. This prevents high-frequency playback state
updates from re-diffing static score content.
Repeated default font loading can cause avoidable JSON decode and glyph cache churn. Keep default font setup idempotent and avoid resetting the music font stack unless it actually changes.
VexFoundationRenderer now publishes lightweight runtime counters:
VexRenderMetrics.reset()VexRenderMetrics.snapshot()
The snapshot includes make/execute counts, total/average/max durations,
and total rendered element estimate. The renderer also emits signpost
intervals for makeRenderPlan and executeRenderPlan (when OSLog
is available), so Instruments can compare before/after behavior.
See CONTRIBUTING.md for setup, contribution checklist, and code style/testing guardrails.
Run the full suite:
swift testRun rendering golden tests only:
swift test --filter renderingGoldenRendering goldens are stored at:
Tests/MusicDisplayKitTests/Fixtures/Goldens
To regenerate all rendering golden PNGs:
UPDATE_GOLDENS=1 swift test --filter renderingGoldenTo regenerate a specific golden test (example):
UPDATE_GOLDENS=1 swift test --filter renderingGoldenOSMDVoiceAlignmentFixtureWhen a golden comparison fails, diff artifacts are written to:
./.build/golden-artifacts
Typical workflow:
- Run
swift test --filter renderingGolden. - If expected visual changes were made, regenerate with
UPDATE_GOLDENS=1. - Re-run
swift test --filter renderingGoldento confirm clean comparisons.