@miadi/ava8-react
v0.3.1
Published
React components and hooks for ABC notation: engraving, playback, a playback cursor on the visible score, transport and volume, and SVG/print export — headless, unstyled, ESM only, over @miadi/ava8-core and @miadi/ava8-abcjs.
Readme
@miadi/ava8-react
React components and hooks for ABC notation — engraving, playback, a cursor on the score you can see, transport, volume and export — with no CSS and no UI-kit dependency.
<AbcScore> renders a tune. <AbcPlayer> renders it, plays it, and shows where
it is playing. useAbcScore, useAbcPlayer, useAbcCursor, useAbcExport and
useGlyphs are the same capabilities without any markup at all, for when you
want to build the component yourself.
Everything musical lives one and two layers down:
@miadi/ava8-abcjs owns the
abcjs binding (one renderer, one playback lifecycle, a pluggable loader), and
@miadi/ava8-core owns the ABC
text model, notes↔MIDI, the instrument registry and the swappable glyph
cosmology. This package restates none of them — it is the React lifecycle around
them.
Install
npm i @miadi/ava8-reactESM 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-react').
react and react-dom are peer dependencies
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}They are not dependencies and never will be: two copies of React in one tree is a broken hooks dispatcher, and the version is the host app's decision. Install React yourself; this package uses the one you already have. It is tested against React 19 and uses no API newer than React 18.
@miadi/ava8-abcjs and @miadi/ava8-core come along as ordinary dependencies —
you do not install abcjs yourself.
0.2.0
Breaking: duration → durationSeconds. UseAbcPlayerResult.duration is
now UseAbcPlayerResult.durationSeconds, which is what Ava8Player calls it one
layer down. This layer had quietly renamed it on the way up, and a rename that
exists in one layer and not the next is a trap for anyone reading both. Rename
the property at your call sites; nothing else about it changed.
-const { duration } = useAbcPlayer(abc)
+const { durationSeconds } = useAbcPlayer(abc)Additive in the same release:
- The playback cursor works.
<AbcPlayer>now hands its engraved tune to the player, so timing events address the score on the page.useAbcCursorwrites a class onto the notes as they sound, on by default in<AbcPlayer>. - The score container is split in two. abcjs mutates the
classandstyleof whatever element it paints into, so it now gets an inner div of its own and React keeps the outer one. - Transport and volume:
pause,resume,seek,setVolume,isPaused,currentMs,volume. useAbcExport:toSvgString(),toSvgBlob(),print().renderErroron<AbcScore>, andwarnings/validationreported apart.reflowOnResize— real responsive engraving.scoreClassName/scoreStyleon<AbcPlayer>, which previously passed neither to the score inside it.- Every
RenderOptionsmember is forwarded now, not eight of them.
This package ships no CSS and no UI kit
There is no styles.css in the published files, no import './x.css' anywhere
in the build, and the only class name this package ever writes is the playback
cursor's, which does nothing until you style it. The build imports exactly four
things: react, react/jsx-runtime, @miadi/ava8-abcjs and @miadi/ava8-core —
a test asserts that list, so it cannot drift.
Concretely, this package will never pull in next/*, @radix-ui/*, tailwindcss,
lucide-react, sonner, react-hook-form, or anything from a components/ui/
folder. The prototype's renderers both hard-coded
className="w-full overflow-x-auto bg-black/30 p-2 rounded-lg", which is one
Tailwind app's opinion compiled into a library. Here every component takes
className and style, and that is the whole styling story.
Data attributes are the only thing on the DOM you did not put there — they exist so a host stylesheet can target state without a class-name contract:
| attribute | on | values |
| --- | --- | --- |
| data-ava8-score | the score wrapper — yours | always present, empty |
| data-ava8-status | the score wrapper | pending | ready | error |
| data-ava8-canvas | the inner div — abcjs's | always present, empty |
| data-ava8-player | the player wrapper | always present, empty |
| data-ava8-playing | the player wrapper | empty while sounding, absent otherwise |
Who owns which element
abcjs writes display:inline-block; position:relative; width:100%;
padding-bottom:<aspect>%; overflow:hidden onto the element you hand it, and
appends abcjs-container to its class. React does not re-assert attributes it
did not change, so an element shared between the two ends up with the consumer's
classes and abcjs's classes in one attribute and a phantom aspect ratio that
outlives every teardown.
So <AbcScore> renders two elements:
<div class="…yours…" style="…yours…" data-ava8-score data-ava8-status="ready">
<div data-ava8-canvas class="abcjs-container" style="display:inline-block;…">
<svg …>…</svg>
</div>
</div>The outer one is React's, byte for byte — a test asserts its class and style
are unchanged across a full render/teardown cycle. The inner one is abcjs's
outright, and is released on unmount and before every re-render through
releaseScoreContainer, which puts back the class and style that clearing
children cannot.
If you call useAbcScore(ref, …) yourself, ref is the abcjs element: give
it a div you have nothing else planned for.
Every module is a client module
Every emitted file opens with "use client", so a Next.js App Router consumer
imports these components from a Server Component with no wrapper of their own.
Nothing here touches window or document at module scope, so an SSR pass that
imports the package does not throw — it simply renders the empty container and
engraves after hydration.
<AbcScore>
import { AbcScore } from '@miadi/ava8-react'
<AbcScore
abc={"X:1\nT:The Spiral Awakens\nM:4/4\nL:1/8\nK:Cmin\nC2E2G2c2|G2E2C4|"}
className="my-score"
responsive
wrap={{ preferredMeasuresPerLine: 4 }}
reflowOnResize
onRendered={(result) => console.log(result.svg)}
onError={(message) => setBanner(message)}
renderError={(message, validation) =>
validation.valid ? <p>Could not draw this: {message}</p> : <ul>{validation.errors.map(…)}</ul>
}
/>AbcScoreProps extends the whole of RenderOptions from @miadi/ava8-abcjs
— responsive, scale, staffWidth, addClasses, padding, clickListener,
tuneIndex, format, selectionColor, dragColor, wrap, tablature,
visualTranspose, ariaLabel, print, strict, abcjsParams, source — and
adds abc, className, style, reflowOnResize, onError, onRendered and
renderError.
- Every option is forwarded. Until 0.2.0 five of them were, by name, and the
rest were accepted from you and dropped. The options object is now compared and
forwarded whole, and a test fails if a member of
RenderOptionsstops taking part — so the next option added one layer down is covered the day it lands. - A failed engraving never throws into your tree. It sets
data-ava8-status="error", callsonErrorwith the message, and rendersrenderErrorif you gave one. warningsandvalidationare different things.validationis core's structural verdict on the ABC and is always present, even before the first engraving and even when engraving failed.warningsis abcjs's parse output.validation.valid === falseis "this is not a tune";validation.valid === truewith an error message is "the tune is fine, abcjs is not here".- Changing
abctears the previous engraving down first, so a slow or failing re-render can never leave the old tune on screen. - Option objects are compared by meaning, not identity:
padding={{ top: 0 }}written inline is a new object every render and does not cause a re-engrave.padding={8}andpadding={{top:8,right:8,bottom:8,left:8}}are one engraving.
reflowOnResize
abcjs's responsive: 'resize' scales one fixed engraving to fit its container —
it never re-breaks the lines, so a sixteen-bar tune on a phone becomes
microscopic rather than becoming four lines. reflowOnResize installs a
ResizeObserver and re-engraves with staffWidth set to the container's actual
width, which is the only way to get a layout made for that width. Pair it with
wrap for line reflow. It costs one extra engraving on mount, because the
observer's first callback is the first time the width is known.
<AbcPlayer>
With no children, you get one unstyled, semantic <button> — no class names,
no icon library:
import { AbcPlayer } from '@miadi/ava8-react'
<AbcPlayer abc={tune} instrumentId="triangle" tempo={96} scoreClassName="score" />With children, it is a render prop over the whole player state, so the transport
is yours:
<AbcPlayer abc={tune} instrumentId="sine" tempo={84} volume={0.8}>
{({ isPlaying, isPaused, toggle, pause, resume, seek, ready, durationSeconds, error }) => (
<MyTransport
label={isPlaying ? 'Stop' : 'Play'}
onPress={toggle}
onHold={isPaused ? resume : pause}
onScrub={(pct) => seek(pct, 'percent')}
disabled={!ready}
seconds={durationSeconds}
error={error}
/>
)}
</AbcPlayer>AbcPlayerProps extends AbcScoreProps and adds instrumentId, program,
tempo, soundFontUrl, volume, autoPlay, showScore, onEnded,
scoreClassName, scoreStyle, highlightClassName and children.
className/style dress the player wrapper; scoreClassName/scoreStyle dress
the score inside it, which until 0.2.0 could not be styled at all.
One engraving, not two. <AbcPlayer> waits for its <AbcScore> to finish,
then hands that engraved tune to the synth as PlayerOptions.visualObj. Before
0.2.0 the two ran independently off the same string, abcjs parsed the tune twice,
and every timing event pointed at a copy that was never on the page.
The playback cursor
<AbcPlayer abc={tune} highlightClassName="ava8-cursor" />On by default — highlightClassName defaults to 'ava8-cursor'. It is free
until you style it, because this package ships no CSS. One line does that:
.ava8-cursor { fill: #e26d5c; stroke: #e26d5c; }Standalone, over a player you built yourself:
import { useAbcPlayer, useAbcCursor } from '@miadi/ava8-react'
function Transport({ abc, visualObj }) {
const player = useAbcPlayer(abc, { visualObj })
useAbcCursor(player, { className: 'ava8-cursor' })
return <button onClick={player.toggle}>{player.isPlaying ? 'Stop' : 'Play'}</button>
}- The class lands on the
<g class="abcjs-note">nodes of the engraving on the page, moves with playback, and comes off on stop and on unmount. - Pausing keeps it where the music stopped; stopping removes it.
- Building the cursor is what turns abcjs's timing loop on.
useAbcPlayeron its own — no cursor, noonEvent, noonBeat— never asks abcjs for one, so a player nobody is watching costs no animation frames. useAbcCursorneeds the engraved tune to be the one playing. Inside<AbcPlayer>that is arranged for you; standalone, passvisualObjfrom your score'sRenderResultintouseAbcPlayer.
useAbcPlayer
The playback half of the prototype's EchoComposer, with none of its form:
import { useAbcPlayer } from '@miadi/ava8-react'
function Transport({ abc }: { abc: string }) {
const { play, stop, toggle, pause, resume, seek, setVolume,
isPlaying, isPaused, currentMs, durationSeconds, volume, ready, error } =
useAbcPlayer(abc, { instrumentId: 'triangle', tempo: 96, onEnded: () => log('done') })
return (
<button onClick={toggle} disabled={!ready}>
{isPlaying ? `Stop (${durationSeconds}s)` : 'Play'}{error}
</button>
)
}- The player is destroyed on
abcchange, not only on unmount. Change the tune while it is sounding and the old synth is stopped and destroyed before the new one is built. currentMsis a ref-backed getter, not state. abcjs's timing fires on every animation frame; publishing that as state would re-render your subtree sixty times a second. Reading it never schedules a render and never costs one. It is refreshed by every state change the hook does publish — play, pause, stop, seek, ready. For a moving readout, drive your ownrequestAnimationFrameand readcurrentMseach frame.setVolumeand thevolumeoption move a playing tune's level through a gain node spliced in front of the speakers. Neither rebuilds the synth.volumeis reported back already clamped to 0..1, so a UI shows the level it will get.seek(position, units)takes'seconds'(default),'beats'or'percent', and moves the audio and the cursor together.play()pressed before the soundfont finishes loading is remembered, not dropped.enabled: falsebuilds no player at all and holdsreadyatfalse. This is how<AbcPlayer>waits for its engraving rather than building a synth it would immediately throw away.- No state is set after unmount: an internal liveness flag is cleared by an effect that is registered first, so it is already false by the time the player's own cleanup runs.
- Callback props (
onEnded,onError,onEvent,onBeat) never take part in a dependency array — a callback changing identity must not destroy a playing tune.
useAbcScore
For when you own the container:
import { useRef } from 'react'
import { useAbcScore } from '@miadi/ava8-react'
function Score({ abc }: { abc: string }) {
const ref = useRef<HTMLDivElement>(null)
const { ready, error, result, warnings, validation } = useAbcScore(ref, abc, {
responsive: true,
reflowOnResize: true,
})
return (
<figure>
<div ref={ref} />
{validation.valid ? null : <ul>{validation.errors.map((e) => <li key={e}>{e}</li>)}</ul>}
</figure>
)
}The element behind ref belongs to abcjs — its children, its class and its
style. Render nothing of your own into it.
useAbcExport
import { useAbcExport } from '@miadi/ava8-react'
const { toSvgString, toSvgBlob, print } = useAbcExport(result)
<button onClick={() => download(toSvgBlob())}>Download SVG</button>
<button onClick={print}>Print</button>Works from the live <svg> a render already produced — never a second
engraving, which could differ from the one on screen — and never mutates it:
everything happens on a clone. The markup it returns stands on its own:
xmlns present, width/height recovered from the viewBox, and abcjs's
position:absolute removed, so it survives being dropped into an <img src> or
a file. print() writes the score into an off-screen <iframe> and prints that,
so no popup blocker is involved and the app around the score is not printed with
it. With nothing engraved, all three report nothing rather than throwing.
useGlyphs
One glyph cursor, reading through @miadi/ava8-core's accessors:
import { useGlyphs, AbcPlayer } from '@miadi/ava8-react'
function Journey() {
const { glyph, glyphId, ids, next, prev, setGlyph } = useGlyphs()
return (
<>
<h2>{glyph.emoji} {glyph.name}</h2>
<AbcPlayer abc={glyph.abcNotation} />
<button onClick={prev}>◀</button>
<button onClick={next}>▶</button>
{ids.map((id) => <button key={id} onClick={() => setGlyph(id)}>{id}</button>)}
</>
)
}next() advances and wraps; prev() retreats and wraps. The direction words come
from the prototype's swipe semantics: next() is core's nextGlyph(id, 'left'),
a right-to-left swipe.
The ids are re-read from core on every render, so a loadMusicData() cosmology
swap is reflected on the next render — including a swap to a cosmology with a
different number of glyphs and no Ava8 glyph in it. If the current glyph is not in
the new cosmology, the cursor resolves to its first glyph. ids keeps its
identity while the cosmology does not change, so it is safe in a dependency array.
A cosmology with no glyphs at all has no honest Ava8Glyph to return, so core's
UnknownGlyphError surfaces.
Ava8Provider / useAva8
One seat for the three decisions the prototype hard-coded in three different files:
import { Ava8Provider } from '@miadi/ava8-react'
import myCosmology from './my-cosmology.json'
<Ava8Provider
source={{ strategy: 'cdn' }}
soundFontUrl="https://my.cdn/soundfonts/"
musicData={myCosmology}
>
<App />
</Ava8Provider>source— where abcjs comes from, for every descendant that does not name its own. A prop always beats the provider.soundFontUrl— the default soundfont for descendant players.musicData— swaps the glyph cosmology by calling core'sloadMusicData()during render, before children render, so a child's firstuseGlyphs()pass already sees the new cosmology instead of flashing the built-in one. On unmount the shipped cosmology is restored viaresetMusicData().
useAva8() returns { source?, soundFontUrl? }, and outside a provider it
returns an empty object rather than throwing.
Testing without a network
Every hook and component accepts an abcjs source. strategy: 'inject' hands the
library your own abcjs, which is how this package's own 71 tests run under jsdom
with no network and no real synth:
const fake = { renderAbc: (el, abc, params) => [/* visualObj */], synth: { CreateSynth } }
render(<AbcScore abc={tune} source={{ strategy: 'inject', abcjs: fake }} />)Absorbed from
Prototype: jgwill/Ava8@3cf080a (branch main), read only. Nothing in that
repository was modified.
| This package | Came from | What changed |
| --- | --- | --- |
| AbcScore | components/abc-notation-renderer.tsx (84 lines, variant A) and components/music-notation.tsx (37 lines, variant B) | Two components that rendered the same thing and disagreed became one. Variant A's next/script CDN load and variant B's import("abcjs") became the source prop over @miadi/ava8-abcjs's loader. |
| AbcScore teardown | abc-notation-renderer.tsx:36-70, music-notation.tsx:12-34 | Both cleared the container at the start of the next render (innerHTML = "" inside the effect) and neither had a cleanup function. Teardown now happens in the effect cleanup — on abc change as well as unmount — and through releaseScoreContainer, because clearing children leaves abcjs's class and style behind. |
| AbcScore error path | abc-notation-renderer.tsx:29-33, 65-69, 80 | Variant A's onError prop survives; its inline <div className="text-red-500"> does not — renderError is the seat for it, and the message arrives with core's validation verdict beside it. Variant B had no error path at all: a rejected import left the container silently empty. |
| container styling | abc-notation-renderer.tsx:81, music-notation.tsx:36 | className="w-full overflow-x-auto bg-black/30 p-2 rounded-lg" was hard-coded in both. Now className/style passthrough on an element React keeps, and the package ships no CSS. |
| useAbcPlayer | components/echo-composer.tsx:29-56 (synth ref + state), 86-120 (stopAllAudio + unmount cleanup), 187-248 (togglePlay) | The playback half of a 403-line form. The form half — react-hook-form, sonner toasts, lucide-react icons, @/components/ui/*, the melody-fetch useEffect and the tempo slider — stayed in the app, where it belongs. |
| player teardown on tune change | echo-composer.tsx:86-90 | The prototype's cleanup was useEffect(() => () => stopAllAudio(), []) — unmount only. Changing the tune mid-playback left the old synth sounding. Now the player is destroyed in the effect cleanup keyed on the tune. |
| the defensive stop sequence | echo-composer.tsx:93-120 | Preserved verbatim in @miadi/ava8-abcjs (synth.stop, synth.midiBuffer.stop, ABCJS.synth.stopAll, each in its own try/catch) — it is the residue of f75b0e1 fix: properly stop audio playback in Echo Composer. |
| the end timer | echo-composer.tsx:229-235 | setTimeout(endsAt * 1000) whose handle was discarded on the line that created it. Fixed one layer down: the timer is held and cleared in stop()/destroy(). |
| AbcPlayer transport | echo-composer.tsx:351-368 | A @/components/ui/button with two lucide-react icons and a bg-red-600 hover:bg-red-700 conditional became a render prop, falling back to one bare <button>. |
| the master volume | app/page.tsx:86-88 | A GainNode and a mute toggle that did not survive the first absorption pass. Reached here through volume / setVolume, applied to a playing tune. |
| instrument → MIDI program | echo-composer.tsx:251-266 | Lifted to @miadi/ava8-core's instrumentMidiProgram — it was data trapped inside a React component. Reached here through PlayerOptions.instrumentId. |
| soundfont URL | echo-composer.tsx:215 | Hard-coded inside a click handler. Now Ava8Provider's soundFontUrl, AbcPlayer's prop, or the default in @miadi/ava8-abcjs. |
| useGlyphs | lib/music-data.ts:52-63 (getNextGlyph), lib/gesture-utils.ts:42-53 (a second getNextGlyph with ["spiral","puzzle","feather","crystal"] hard-coded), and app/page.tsx importing the hard-coded one | Three forks of one idea, one of which a music-data.json swap could not reach. This hook adds no fourth copy: it calls core's nextGlyph(). |
| swipe semantics | hooks/use-swipe.tsx, lib/gesture-utils.ts:19-39 | Gesture detection stays in the app — it is input handling, not music. useGlyphs keeps the vocabulary it fed: next() is a right-to-left swipe. |
| useAbcCursor | nothing — new | The prototype had no cursor. Neither did 0.1.0 of this package, and it could not have had one: the score and the player engraved the tune separately. |
| useAbcExport | nothing — new | The prototype could not get a score back out of the page. |
| Ava8Provider | nothing — new | The prototype had no seat for these decisions: the loader was hard-coded in two components, the soundfont in a click handler, and the cosmology in a module-level import. |
Deviations from the frozen contract
All additive, all documented at their definition:
useAbcScore(ref, …)takesRefObject<HTMLElement | null>where the contract writesRefObject<HTMLElement>. Under@types/react19,RefObject<T>is{ current: T }, so the bare form would reject theuseRef<HTMLDivElement>(null)every consumer actually writes.AbcPlayerPropsadds an optionalsoundFontUrl. Without it, changing the soundfont for a single player would mean wrapping it in its ownAva8Provider.UseAbcPlayerOptionsaddsenabled. Without it<AbcPlayer>cannot wait for its own engraving, and the cursor's whole mechanism — reusing the visible tune — has nothing to reuse.UseAbcScoreOptionsaddsreflowOnResize.
Tests
npm test # builds, then runs node --test over test/*.test.mjs71 cases under jsdom + react-dom/client + act, driving a fake abcjs through
strategy:'inject'. The fake paints real SVG, keeps real synth state, holds a
real gain node and mutates its container exactly the way abcjs does — so the
assertions are about engraved DOM, about which node carries the cursor, about
whether anything is still sounding and about what level it is sounding at. Never
about whether a function was called.
