Score Engine
@viritura/score-engine is the rendering kernel. It takes MNX in and gives
you layout, ink-only Canvas painting, SVG, geometry queries and a playback
timeline. Use it directly when the Score Viewer
doesn't fit, for example when you manage your own canvas, overlays, scrolling
or export pipeline.
Load the engine
npm install @viritura/score-engine
import { loadEngine } from "@viritura/score-engine";
const engine = await loadEngine({ assetBaseUrl: "/score-engine/" });
Without a bundler, import score-engine.js from the
release archive by URL instead, and omit
assetBaseUrl.
loadEngine fetches the WebAssembly and fonts once. Concurrent calls share the
same load, and every later call returns the same engine.
| Option | Default | Notes |
|---|---|---|
assetBaseUrl |
the module's own directory | Base URL containing wasm/, fonts/ and score-engine.worker.js |
textFont |
true |
false to skip Libertinus Serif and use the page's serif font |
If loading fails, the promise rejects with EngineLoadError. Its code is
wasm or font, and cause holds the underlying error. Calling
loadEngine again retries.
Lay out and paint
const displayList = engine.layout(mnx, { pageWidth: 800 });
const page = engine.measure(displayList).pages[0];
const canvas = document.querySelector("canvas");
const dpr = window.devicePixelRatio || 1;
canvas.width = page.width * dpr;
canvas.height = page.height * dpr;
canvas.style.width = `${page.width}px`;
canvas.style.height = `${page.height}px`;
const ctx = canvas.getContext("2d");
ctx.scale(dpr, dpr);
ctx.fillStyle = "#fff"; // the paper is yours
ctx.fillRect(0, 0, page.width, page.height);
engine.paint(ctx, displayList, { page: 0 });
layout returns a DisplayList, an opaque handle with width, height,
pageCount and paged. Its internals aren't part of the API; pass it back to
the engine to paint or query it. Layout units are CSS pixels at zoom 1.
paint draws one page's ink under whatever transform the context already has,
so you control device-pixel scaling, zoom and scrolling with ctx.scale and
ctx.translate. It never clears the canvas and never paints paper.
Layout options
Changing any of these reflows the music, so they need a new layout call.
| Option | Default | Notes |
|---|---|---|
pageWidth |
required | Page width in layout units. 0 selects unpaged layout |
spatium |
7 |
Staff-space height, which sets the staff size |
scoreIndex |
0 |
Which entry of the MNX scores array to render |
pageSetup |
engine page proportions | { height, margins: { top, right, bottom, left } } |
With pageWidth: 0 the whole document is one continuous system on a single
page. engine.horizonPaper(displayList) returns the paper rectangle around the
music, plus the height a viewport needs to centre it.
Paint options
| Option | Default | Notes |
|---|---|---|
page |
0 |
Zero-based page |
region |
none | Page-local rectangle; anything entirely outside it is skipped |
ink |
none | Replaces default black ink, for dark themes. Explicit colours are kept |
background |
none | Fill painted under the ink, for when you don't draw paper yourself |
Use region to repaint only the visible part of a tall page.
Errors
| Error | Thrown by | Meaning |
|---|---|---|
EngineLoadError |
loadEngine |
The WebAssembly or fonts failed to load |
ParseError |
layout, info, timeline |
The input isn't a readable MNX document |
LayoutError |
layout |
The engine failed while laying out |
Each error has a code with the specific reason and a cause with the
underlying error, so you can use instanceof and code instead of matching
messages.
Score information
const info = engine.info(mnx);
// { parts: [{ id: "flute", index: 0, name: "Flute" }], measureCount: 32, scores: [{ index: 0, name: "Full score" }] }
info reads the document without running layout. Use scores to offer a
choice of MNX score definitions, then pass the chosen index as scoreIndex.
Part identifiers follow the
stable part identifier rule.
Geometry
All geometry is in layout units and page-local: y = 0 is the top of that
geometry's page.
| Method | Returns |
|---|---|
measure(displayList) |
Page sizes, page offsets, total height, widest page and parts |
systems(displayList) |
Each system's page and rectangle, in reading order |
measures(displayList) |
Each measure on each staff: part, staff, system, page, rectangle, content start and length in beats |
positionToCanvas(displayList, position) |
Where a musical position lands on its part's top staff |
playhead(displayList, position) |
A cursor spanning the whole system at a musical position |
canvasToBeat(displayList, page, x, y) |
The musical position under a page-local point |
A position is either { beat }, a global quarter-note beat, or
{ measureIndex, beat }, a beat within a measure. Add partId to pick the
staff it refers to.
canvas.addEventListener("click", (event) => {
const rect = canvas.getBoundingClientRect();
const hit = engine.canvasToBeat(displayList, 0, event.clientX - rect.left, event.clientY - rect.top);
if (hit) console.log(`measure ${hit.measureIndex + 1}, beat ${hit.beat}, part ${hit.partId}`);
});
This example assumes zoom 1 and no scrolling. Divide by your zoom and add your scroll offset first if you use them.
Playback timeline
const timeline = engine.timeline(mnx);
// { totalBeats, totalSeconds, partIds, events, tempoMap }
The timeline says what plays when. It is deterministic and independent of
layout, so the same music always produces the same timeline whatever the page
size, and it can be computed on a server. Repeats and jumps are expanded by
default; pass { repeatExpansion: "ignore" } for one pass in written order.
Each event has a partId, beat, durationBeats, timeSeconds, MIDI pitch,
an explicit dynamic when one is marked, and the MNX event id when the
document has one. tempoMap lists every tempo change in beats and seconds.
The engine doesn't play audio. Feed the timeline to Web Audio, a MIDI output or
your own synthesiser, and pass the current beat to playhead to draw a cursor.
SVG export
const svg = await engine.toSvg(displayList, { page: 0, ink: "#000" });
Returns a standalone SVG for one page. Text and glyphs are converted to outlines, so the file displays the same without Viritura's fonts installed.
Large documents
- Off the main thread.
engine.createLayoutWorker()runs layout in a module worker. Itslayout(mnx, options)has the same contract asengine.layoutbut returns a promise. Calldispose()when finished; pending layouts then reject. - Very long unpaged scores.
engine.createTileRenderer()paints the visible part of an unpaged layout from cached tiles. Callpaint(canvas, displayList, { scrollX, scrollY, zoom, background, ink })on every scroll or frame; it returnstruewhile tiles are still pending, so request another frame. Callinvalidate()after a theme change.
Version
engine.version; // { engine: "0.1.0", package: "0.1.0", commit: "9e3ce8f1…" }
engine is the WebAssembly layout engine version, package is the
@viritura/score-engine version, and commit is the source commit of a
prebuilt bundle.