@miadi/ava8-abcjs
v0.3.1
Published
The abcjs binding for Ava8: one score renderer with a pluggable loader (module/global/cdn/inject), multi-tune tunebook rendering, standalone SVG export, synth playback with a playback cursor, master volume and a leak-free lifecycle, ABC->MIDI bytes, and i
Downloads
622
Readme
@miadi/ava8-abcjs
The abcjs binding of Ava8 — one score renderer, one playback lifecycle, and a loader that lets abcjs arrive from wherever your runtime can reach it.
No React, no Next.js, no UI kit. It runs in a browser, and with the optional
jsdom it runs headless in node — which is what ava8 render is built on.
Everything musical that does not need abcjs lives one layer down in
@miadi/ava8-core: the ABC
text model, notes↔MIDI, the instrument registry, tempo naming and the swappable
glyph cosmology. This package uses core rather than restating it.
Install
pnpm add @miadi/ava8-abcjsESM only. There is no CommonJS build and no require() entry point: the
package is "type": "module" with a single import condition. In a CommonJS
file, reach it with await import('@miadi/ava8-abcjs').
abcjs comes with it, types included — you never add abcjs to your own
dependencies to describe a visualObj, because this package re-exports
TuneObject, AbcElem and NoteTimingEvent. jsdom does not come with it: it
is an optional peer, wanted only by the node-side rendering paths. A browser
consumer never installs it.
Why abcjs is pinned to 6.6.4
Three declarations in the prototype disagreed with each other:
| Where | What it said |
| --- | --- |
| package.json:41 | "abcjs": "latest" |
| pnpm-lock.yaml:1388 | latest had resolved to 6.6.0 |
| components/abc-notation-renderer.tsx:75 | a jsdelivr <script> pinned to 6.2.2 |
So one page could run two different abcjs builds at once — 6.6.0 through
import("abcjs") in music-notation.tsx, and 6.2.2 through the <Script> tag
in abc-notation-renderer.tsx. Two engravers, two synth implementations, one
app.
Here there is one constant, abcjsPinnedVersion, and both the npm dependency and
the default CDN URL are built from it — strategy:'cdn' fetches
[email protected], never the stale 6.2.2. It is pinned exactly, not with a
caret: the render tests assert on real SVG markup, and abcjs's engraving output
is version-sensitive, so a floating range would turn a dependency bump into a
mystery test failure. 6.6.4 is the newest patch of the 6.6 line the
prototype's lockfile had already resolved to, so it is the version the prototype
actually ran, with its patches.
import { abcjsPinnedVersion, defaultAbcjsCdnUrl } from '@miadi/ava8-abcjs'
abcjsPinnedVersion // '6.6.4'
defaultAbcjsCdnUrl // 'https://cdn.jsdelivr.net/npm/[email protected]/dist/abcjs-basic-min.js'Two renderers became one
The prototype wrote the same feature twice, and the two copies disagreed:
| | abc-notation-renderer.tsx (A) | music-notation.tsx (B) |
| --- | --- | --- |
| how abcjs arrives | next/script from jsdelivr, then window.ABCJS | import("abcjs") inside the effect |
| responsive | a prop, default 'resize' | hard-coded 'resize' |
| scale | a prop, default 1 | hard-coded 0.8 |
| staff width | 740 when not responsive | hard-coded 500 |
| click listener | an empty one, "to enable note highlighting" | none |
| errors | onError callback + an inline message | swallowed |
Neither was wrong; they were two answers to a question nobody had asked out loud. The reconciliation:
- Loading became a parameter, not a fork in the code —
AbcjsSourcewith four strategies. B's dynamic import isstrategy:'module', the default. A's script tag isstrategy:'cdn', now pointing at the pinned version. - A's defaults win every conflict but one.
responsiveandscaleare options rather than constants, the staff width default is740, and an emptyclickListeneris always supplied so abcjs installs its note-selection handling. B's500/0.8were one component's layout preference; they belong in a caller's props, not in a library. - Padding is the exception. Variant A forced all four sides to
0, and that choice reached every consumer of this package. Measured ink bounds at740×439: left0, right0, bottom0—R:airshaved toairat the left edge, the composer line against the right edge, the last staff cut off. Ask forpaddingand you get it; ask for nothing and the fourpadding*keys are not sent at all, so abcjs's own margins apply. - Errors are returned, not swallowed —
renderScoreresolves with abcjs'swarningsand core'svalidation, separately, and throws only when abcjs itself fails or when you asked forstrict.
One function, renderScore, now covers both call sites.
The four loader strategies
type AbcjsSource =
| { strategy: 'module' } // import('abcjs') — the default
| { strategy: 'global' } // window.ABCJS, already on the page
| { strategy: 'cdn'; url?: string } // inject a <script>, default = the pinned jsdelivr build
| { strategy: 'inject'; abcjs } // you hand us the module| Strategy | Use it when |
| --- | --- |
| 'module' | You have a bundler, or you are in node. abcjs is a real dependency and gets code-split like any other. Start here. |
| 'global' | The host page already loads abcjs — a CMS, a Rails asset pipeline, another widget on the same page. Nothing is fetched; a missing global fails loudly instead of quietly loading a second copy. |
| 'cdn' | No bundler: a plain <script type="module"> page, or a Next.js app that wants abcjs off the critical path. This is the prototype's variant A, with the version drift closed. |
| 'inject' | Tests, SSR, and any runtime where you have already resolved abcjs yourself. Every test in this package uses it to drive the player without a browser. |
import { loadAbcjs, setDefaultAbcjsSource } from '@miadi/ava8-abcjs'
setDefaultAbcjsSource({ strategy: 'cdn' }) // process-wide default
const abcjs = await loadAbcjs() // or pass a source per callConcurrent calls share one in-flight promise: two components mounting in the
same tick inject one <script>, not two. A load that rejects is evicted from
the cache as it rejects, so one failed CDN fetch does not poison the process — a
later call retries. Failures throw AbcjsLoadError, which carries the
strategy that failed.
Rendering
import { renderScore, renderScoreToSvgString } from '@miadi/ava8-abcjs'
import { getGlyph } from '@miadi/ava8-core'
// In a browser — into an element you own. The container is cleared first.
const { svg, visualObj, warnings, validation, cleanup } =
await renderScore(el, getGlyph('spiral').abcNotation)
// Headless — one standalone SVG document. This is what `ava8 render` runs.
const svgText = await renderScoreToSvgString(getGlyph('spiral').abcNotation)Options
| Option | Default | What it does |
| --- | --- | --- |
| responsive | true for renderScore, false for the string forms | abcjs responsive:'resize' |
| scale | 1 | Ignored while responsive is on — see below |
| staffWidth | 740 when responsive is off | Honoured in both modes when you pass it |
| addClasses | true | The abcjs-note / abcjs-staff classes highlighting needs |
| padding | abcjs's own margins | 12, or { top, right, bottom, left } — sides you leave out fall back independently |
| clickListener | an empty one | Receives one named AbcClickEvent, not five positional unknowns |
| tuneIndex | 0 | Which tune of a multi-tune ABC to engrave -> abcjs startingTune |
| format | — | abcjs's 25 typography attributes, so a score can match a design system |
| selectionColor, dragColor | abcjs's hard-coded #ff0000 | The worst possible colour on a dark theme |
| wrap | — | Real line reflow. With staffWidth, this is how a score fits a phone |
| tablature | — | Guitar / mandolin tab |
| visualTranspose | — | Engrave in another key without touching the ABC |
| ariaLabel | abcjs's Sheet Music for "<title>" | |
| print | — | abcjs print: true, page layout rather than screen layout |
| strict | false | Reject instead of painting when validateAbc() reports errors |
| abcjsParams | — | The escape hatch, applied last and overriding everything above |
abcjsParams is there so this package is never the reason you cannot reach
abcjs. Anything not named above — jazzchords, oneSvgPerLine, lineBreaks,
germanAlphabet, showDebug — goes through verbatim:
await renderScore(el, abc, { abcjsParams: { jazzchords: true, oneSvgPerLine: true } })scale and responsive do not combine
abcjs drops scale whenever responsive is on — engraver-controller.js reads
if (responsive === "resize") scale = undefined. A responsive score is sized by
its container, which is what makes it responsive. Since responsive defaults to
true for renderScore, scale: 2 on the default path has always been inert;
now it says so on console.warn rather than quietly doing nothing. Pass
responsive: false for scale to mean anything.
Warnings and validation are two different things
const { warnings, validation } = await renderScore(el, 'X:1\nT:Keyless\nM:4/4\nCDEF|')
warnings // [] — abcjs engraves it happily and says nothing
validation // { valid: false, errors: ['Missing K: key header', …], warnings: [] }warnings is abcjs's parse output alone. validation is core's validateAbc
verdict, unflattened — an error stays an error and a warning stays a warning.
renderScore(el, '') and a keyless tune both used to come back with an svg,
status: 'ready' and nothing at all to distinguish them from a clean render.
Pass strict: true and a tune with errors rejects instead of painting.
The container is yours, and abcjs will write on it
abcjs mutates the element you hand it: display:inline-block; position:relative;
width:100%; padding-bottom:<aspect>%; overflow:hidden, plus abcjs-container
appended to its class. Clearing innerHTML undoes none of that, so a div that
once held a score keeps a phantom aspect ratio forever.
const result = await renderScore(el, abc)
result.cleanup() // clears it and puts back the original class/style
releaseScoreContainer(el) // the same, when you did not keep the result@miadi/ava8-react should still pass an inner div it owns exclusively:
restoring attributes cannot restore a layout a host stylesheet already computed
against the phantom padding.
Multi-tune ABC
renderAbc paints one tune per target element, so a two-tune tunebook handed to
one element came back with one svg and warnings === [] — tune two gone with
zero signal, while core's symphonyToAbc emits multi-tune ABC by design.
import { renderTunebook, renderTunebookToSvgStrings } from '@miadi/ava8-abcjs'
const { tunes } = await renderTunebook(el, abc) // one owned child <div> per tune
tunes[1].svg // the second movement, engraved
const svgs = await renderTunebookToSvgStrings(abc) // one standalone document eachrenderScore still engraves one tune — tuneIndex picks which — but it now
reports tuneCount and pushes a warning naming what it left out. Silent
truncation is gone either way.
The string forms are standalone documents
renderScoreToSvgString used to return svg.outerHTML, which carries
xmlns:xlink and no xmlns="http://www.w3.org/2000/svg" — rejected by
<img src>, by Inkscape and by every XML parser. It now serialises through
XMLSerializer, which writes the namespace.
It also inherited responsive: true, so the string carried position:absolute
with no width or height: measured in a browser, the host div collapsed to 4px
and the score jumped to the corner of the viewport. A live element has a
container to be responsive to; a string does not, so responsive defaults to
false here. standalone (default true) guarantees dimensions and no absolute
positioning even when you ask for responsive: true anyway; pass
standalone: false for abcjs's raw fragment.
Document resolution for the string forms, in order: the document you pass,
the global one, then a JSDOM built on demand. Only the last needs the optional
dependency; without it you get a MissingDomError that says so.
abcjs reads the bare document global rather than an element's ownerDocument,
so on the node path this package publishes the element's own view onto
globalThis for the duration of the call and restores the previous descriptors
in a finally. Importing this package in bare node touches no DOM global at all.
A score at one moment — renderScoreFrameToSvgString
The cursor below runs in a browser, driven by sound. renderScoreFrameToSvgString
asks the same question with no audio, no browser and no TimingCallbacks: what
does this score look like at 8400 ms?
import { renderScoreFrameToSvgString, scoreTimings } from '@miadi/ava8-abcjs'
const timeline = await scoreTimings(abc)
// [{ index: 0, startMs: 0, durationMs: 333 }, { index: 1, startMs: 333, ... }]
const frame = await renderScoreFrameToSvgString(abc, { atMs: 8400 })
const first = await renderScoreFrameToSvgString(abc, { noteIndex: 0 })The note sounding at that moment carries cursorClassName (default
'ava8-cursor' — the same class useAbcCursor writes in @miadi/ava8-react, so
one stylesheet rule dresses a live cursor and a rendered frame alike). No CSS
ships with it:
.ava8-cursor { fill: #e26d5c; stroke: #e26d5c; }Everything the string forms guarantee still holds, because it is the same code
producing it: xmlns, real width / height, responsive off, standalone
on, and every engraving option above. A frame opens in <img src> exactly as a
plain export does.
| | |
| --- | --- |
| atMs or noteIndex | two ways to name one moment. Passing both throws — a caller who believes they are different is about to get the next frame wrong too. |
| out of range | not an error. Before the first note or past the last, you get the score with nothing lit: a film's lead-in and its tail are legitimate frames of the same tune. |
| neither | the plain export, byte for byte. No moment named, no note lit. |
| tempo | qpm, clamped by core's clampTempo, so a frame and createPlayer({ tempo }) cannot disagree. Omitted means the tune's own Q:. |
scoreTimings engraves to obtain the numbers — abcjs computes them against the
elements it drew — but nothing engraved survives the call. Rests are counted:
abcjs emits them as timing events with real elements, a browser cursor moves
onto them, and noteIndex addresses the same sequence a browser cursor walks.
Playback
import { createPlayer, stopAllAudio, unlockAudio } from '@miadi/ava8-abcjs'
await unlockAudio() // iOS wants a user gesture before any sound
const player = await createPlayer(abc, {
instrumentId: 'triangle', // -> GM program 73, resolved through @miadi/ava8-core
tempo: 96, // qpm; omitted -> the tune's own Q: header
soundFontUrl: 'https://paulrosen.github.io/midi-js-soundfonts/FluidR3_GM/', // default
volume: 0.71, // 0..1, live
onEnded: () => setPlaying(false),
onError: (err) => toast.error(err.message),
})
await player.play()
player.durationSeconds // seconds, re-timed whenever setTempo is called
player.currentMs // milliseconds into the tune
player.isPlaying
player.setTempo(132) // clamped to Ava8's 40..240; applies on the next play()
player.setVolume(0) // the mute toggle
player.pause()
player.resume()
player.seek(0.5, 'percent') // or 'seconds' (default) or 'beats'
player.stop()
player.destroy() // always call this on unmountThe lifecycle abcjs demands is preserved exactly: new synth.CreateSynth() →
init({ visualObj, options }) → prime() → start().
onError is the error channel when you supply one — play() reports and
resolves. Without it, play() rejects. The error is never lost and never
delivered twice.
pause, resume and seek are a mapping onto abcjs's MidiBuffer, which
already has all three; the end timer is re-armed on resume() for the time that
is actually left, so a pause never shortens a tune.
The playback cursor
Hand the player the tune you already engraved and the cursor addresses the visible svg:
const { visualObj } = await renderScore(el, abc)
const player = await createPlayer(abc, {
visualObj, // <- this is the whole trick
onEvent: (e) => e?.elements?.[0]?.[0]?.classList.add('is-playing'),
onBeat: (beat, totalBeats, totalTime) => setProgress(beat / totalBeats),
beatSubdivisions: 2,
})Without visualObj, createPlayer parses the ABC a second time into
renderAbc('*') — a throwaway target. The audio is identical, but every
elements[0][0] on a timing event belongs to that detached parse: real <g
class="abcjs-note …"> nodes that are not on the page, so highlighting them
paints nothing. There was no way for a consumer holding the right object to hand
it over. Now there is.
A TimingCallbacks is built only when onEvent or onBeat is supplied — an
unwatched requestAnimationFrame loop per silent player is not free — and it is
started and stopped alongside synth.start() / stop().
Volume
abcjs 6.6 has no volume API. CreateSynth bakes
soundFontVolumeMultiplier into the rendered buffer at prime(), where it can
no longer move, and then connects every directSource[n] straight to
context.destination. That edge is the only place a live level can live, so
volume / setVolume splices a GainNode into exactly that edge — which is
what the prototype's master gain (app/page.tsx:86-88) did, and what core's
defaults.volume: 71 has been describing to nobody ever since.
Volume changes ramp with the prototype's setTargetAtTime(…, 0.01); the initial
attach is immediate, because a ramp there would fade in the start of every tune.
A runtime with no Web Audio graph cannot have a live level, and says so on
console.warn once rather than no-oping in silence.
ABC → MIDI
import { abcToMidiBytes, abcToMidiBlob } from '@miadi/ava8-abcjs'
const bytes = await abcToMidiBytes(abc, { instrumentId: 'sawtooth', tempo: 90 })
bytes instanceof Uint8Array // true — node and browser get the same value
await fs.writeFile('tune.mid', bytes) // node
const blob = await abcToMidiBlob(abc) // additive browser convenience, type 'audio/midi'abcToMidiBytes is the contract export and returns bytes so node and the browser
agree. abcToMidiBlob is additive: it exists so a download button does not
have to re-wrap the bytes itself.
Audio unlock
import { getAudioContext, unlockAudio, isIOSDevice } from '@miadi/ava8-abcjs'getAudioContext() returns one lazily created shared context, or null where
there is no window. unlockAudio() resolves true once the context is
running. Concurrent callers share one registration.
Additive exports
Beyond the frozen absorption contract:
| Export | Why |
| --- | --- |
| abcToMidiBlob | the browser wrapper around abcToMidiBytes |
| abcjsPinnedVersion, defaultAbcjsCdnUrl | the one version constant, readable by consumers |
| abcjsRenderParams | the reconciled defaults as data, so @miadi/ava8-react and the ava8 CLI apply one set rather than three |
| resetAbcjsCache, getDefaultAbcjsSource | loader control for tests and for runtime CDN swaps |
| defaultSoundFontUrl | the prototype's hard-coded soundfont, now nameable |
| renderTunebook, renderTunebookToSvgStrings | multi-tune ABC, which core emits and this layer used to drop |
| renderScoreFrameToSvgString, scoreTimings | the score at a moment, and the timeline it is a moment of — noteTimings was declared here and never read |
| releaseScoreContainer | undo abcjs's mutations of a container you own |
| clampVolume | the same 0..1 clamp setVolume applies, so a slider can show the value it will get |
| Ava8AbcjsError, AbcjsLoadError, MissingDomError | typed failures |
| AbcClickEvent, AbcjsNoteTimingEvent, AbcjsProgressUnit, AbcjsTimingCallbacks | the abcjs shapes this package hands you, named |
| TuneObject, AbcElem, NoteTimingEvent, AbcVisualParams | abcjs's own declarations, re-exported so you never install them yourself |
Absorbed from
Source prototype: jgwill/Ava8@3cf080a (branch main). The prototype is read
only — nothing here writes back to it.
| Here | Absorbed from | Change on the way in |
| --- | --- | --- |
| src/loader.ts 'cdn' | components/abc-notation-renderer.tsx:24-33,74-79 — next/script + handleScriptLoad/handleScriptError | next/script replaced with a plain <script> injection; concurrent callers deduped; the URL now carries the pinned version instead of 6.2.2 |
| src/loader.ts 'module' | components/music-notation.tsx:15 — import("abcjs").then(...) | the same import, cached and shared, with the CommonJS default interop the prototype never needed inside webpack |
| src/loader.ts 'global', 'inject' | (new) | what makes the reconciliation testable and SSR-safe |
| src/render.ts renderScore | components/abc-notation-renderer.tsx:36-70 and components/music-notation.tsx:12-34 | the two renderers merged; variant A's defaults win (see above); innerHTML = "" clearing preserved from both |
| src/render.ts renderScoreToSvgString | (new) | the headless path the prototype had no way to reach |
| src/render.ts warnings | components/abc-notation-renderer.tsx:64-69 | variant A surfaced errors through onError, variant B swallowed them; now both abcjs parse warnings and core validateAbc findings are returned |
| src/player.ts lifecycle | components/echo-composer.tsx:187-248 togglePlay | lifted out of a React component and off useState; end timer tracked and cleared (see below); stop() during init/prime no longer starts a stale synth |
| src/player.ts stop() | components/echo-composer.tsx:93-120 stopAllAudio | preserved deliberately; split from two try blocks into three, one per call |
| src/player.ts defaultSoundFontUrl | components/echo-composer.tsx:215 | the same FluidR3_GM URL, now an overridable option |
| src/player.ts program / tempo | components/echo-composer.tsx:216-217 | getInstrumentMidiProgram moved to @miadi/ava8-core in the previous layer; this package calls instrumentMidiProgram and clampTempo rather than restating them |
| src/midi.ts | (new) | abcjs.synth.getMidiFile, normalised to Uint8Array; the prototype could only export MIDI from a note array, never from ABC |
| src/audio.ts | lib/audio-context.ts:1-62 | getAudioContext and isIOSDevice unchanged; initializeAudio became unlockAudio with the listener leak closed (see below) |
| src/types.ts | types/abcjs.d.ts:1-17 | the same members, with any replaced by types strict can check, and the midiBuffer shape kept because the teardown still guards for it |
Two bugs closed while porting
The stale end timer. echo-composer.tsx:229-235 scheduled
setTimeout(() => { setIsPlaying(false) }, durationInSeconds * 1000)and never held the handle. Stop a tune three seconds in, start it again, and the
first run's timer was still alive — it fired at its original ten-second mark and
flipped isPlaying false in the middle of the second run. Here the handle is
tracked and cleared in both stop() and destroy().
The port also found that the value the prototype timed from,
visualObj.endsAt, does not exist in abcjs 6.6 — the timer would have been
scheduled from undefined. Duration now comes from abcjs 6's real API,
setTiming(bpm, 0) then getTotalTime(), refined by synth.duration after
init(), with endsAt kept as a fallback for older builds reached over the CDN.
The leaked gesture listeners. lib/audio-context.ts:47-49 registered three
{ once: true } listeners and removed them from inside an async handler.
Whichever gesture arrived first resolved the promise and removed only itself —
the other two survived and fired on some later, unrelated tap. unlockAudio()
now latches on the first gesture and removes all three synchronously, before
awaiting resume().
Deliberately preserved
stop() calls synth.stop, synth.midiBuffer.stop and ABCJS.synth.stopAll,
each inside its own try/catch. In abcjs 6.6 the last two do not even exist —
CreateSynth has no midiBuffer and the synth namespace has no stopAll.
That is not a reason to delete them. This is the residue of
f75b0e1 fix: properly stop audio playback in Echo Composer, a bug that was
real, and the guards are what let one abcjs build's missing method not stop the
others from being called. A test asserts that a throwing synth.stop still
leaves midiBuffer.stop and stopAll to run.
Build and test
npm run build # ../../node_modules/.bin/tsc -p tsconfig.build.json, strict, zero errors
npm test # build, then node --test test/*.test.mjs83 tests, asserting engraved SVG markup, measured widths, ordered call logs, argument values, audio-graph topology, MIDI bytes and timer state:
renderScoreToSvgStringon every glyph in the shipped cosmology, checking staff, clef, key signature, note heads, stems, bar lines and<path>geometry — not merely that a string came back- the exported string parsed back as XML, checking
documentElement.namespaceURI - padding:
770wide by default,740withpadding: 0,795withpadding: { left: 40 }— one side named leaves the other three alone wrapturning one 1305px staff line into seven that fit in 430px- two tunes in, two svgs out,
Tune Onein the first and not in the second - the container's
classandstylerestored byte for byte aftercleanup() - the loader injecting exactly one
<script>for two concurrent calls, and retrying after a rejection - the player lifecycle driven against an injected fake, asserting
new CreateSynth → init → prime → startwith the resolvedprogram,qpmandsoundFontUrl - volume: every buffer source disconnected from the speakers and reconnected to
a gain node, in that order, with the gain reaching
destinationlast - the cursor built against the tune the reader can see — driven through real
abcjs, asserting
elements[0][0].isConnectedandsvg.contains(node)for all seven notes - seven frames across one tune, each lighting a different engraved
<g>— thedata-indexand the notehead's drawn x coordinate advancing left to right, never a class merely present somewhere in the markup atMs: 2666lit andatMs: 2667dark, on the boundary abcjs's ownendevent puts there, andnoteIndex: nbyte-for-byte identical to theatMsinside it- the stale-timer regression: play, stop at 3s, replay, advance past the first
run's 10s mark, and
isPlayingis still true - pause for an hour, resume, and the tune still has exactly its remaining six seconds
unlockAudioremoving all three listeners, and a later gesture resuming nothing- every instrument id reaching the MIDI bytes as a real
0xC0program change
One test walks the built output and fails if a DOM global is dereferenced outside
src/env.ts, or if jsdom or abcjs ever becomes a static import.
