@miadi/ava8
v0.3.1
Published
The Ava8 symphony as a Miadi package — umbrella re-export of the ABC core, abcjs binding and React layer, plus the ava8 CLI (render, tunebook, midi, symphony, serve) and a standalone symphony server. ESM only.
Readme
@miadi/ava8
The Ava8 symphony, absorbed from jgwill/Ava8@3cf080a into four packages that can
be taken separately.
ESM only. Every one of the four is "type": "module" with a single import
condition and no CommonJS build. require('@miadi/ava8') fails with
ERR_PACKAGE_PATH_NOT_EXPORTED; from a CommonJS file, reach it with
await import('@miadi/ava8'). Node 18.17 or newer.
0.2.0
Four packages moved together. Two of the changes are breaking.
Breaking — notesToMidiBytes is async. It returns Promise<Uint8Array>.
-const bytes = notesToMidiBytes(glyph.notes, { tempo: 108 })
+const bytes = await notesToMidiBytes(glyph.notes, { tempo: 108 })[email protected] is pure CommonJS. Importing it statically put a CommonJS
module in @miadi/ava8-core's ESM graph, and the failure landed at module
evaluation — so it took down every unrelated export of core, and of
@miadi/ava8-abcjs through it, before a line of code ran. A no-bundler browser
page could not import this package at all. The import now lives inside the one
function that needs it, which is what makes that function async. There is no
notesToMidiBytesSync.
Breaking — UseAbcPlayerResult.duration is now durationSeconds, named
exactly as Ava8Player.durationSeconds one layer down.
Everything else is additive:
| Package | New in 0.2.0 |
| --- | --- |
| @miadi/ava8-core | abcBodyToNotes transcribes an ABC tune into note specs, so a melody is written once instead of twice. notesToMidiBytes takes chords, rests and per-note durations. abcKeyAccidentals, UnsupportedAbcError, NoteSpec. |
| @miadi/ava8-abcjs | renderScoreToSvgString takes SvgExportOptions and serialises through XMLSerializer, so the markup carries xmlns and its own width/height. renderTunebook / renderTunebookToSvgStrings paint every tune. RenderResult carries tuneIndex/tuneCount. The player grew pause, resume, seek, setVolume, currentMs and a playback cursor. |
| @miadi/ava8-react | pause/resume/seek/setVolume on the hook, a cursor on by default (highlightClassName), useAbcCursor, useAbcExport, scoreClassName/scoreStyle, renderError. |
| @miadi/ava8 (this package) | ava8 tunebook, and the engraving flags --scale, --staff-width, --responsive, --padding, --print, --no-standalone, --tune-index, --selection-color — which parsed and were then discarded for the whole of 0.1.0. An unknown flag is now an error. The served player page gained a transport and a cursor. |
Two defects this release closes, both of which produced no error message at all:
ava8 rendercalled the renderer with zero options. Every display flag parsed and vanished; the output was byte-identical whatever you passed. There was no CLI invocation that produced a correctly-sized SVG.- That output carried no
xmlnsand, in responsive mode, nowidth/height. Dropped into a host page it collapsed to 4px in the viewport corner, and neither a browser nor Inkscape would open the file as SVG.
Build status on this branch
All four packages are built and green.
| Package | State on this branch |
| --- | --- |
| @miadi/ava8-core | built — node --test, including a resolver hook that makes midi-writer-js unreachable and imports the package anyway |
| @miadi/ava8-abcjs | built — abcjs pinned exactly 6.6.4 |
| @miadi/ava8-react | built — jsdom + react-dom/client |
| @miadi/ava8 (this package) | built — 86 assertions: every CLI verb as a spawned process, the server over real HTTP, the storage layer against a real filesystem, example/node/compose.mjs as a consumer, the vanilla example resolved against a planted hoisted node_modules as well as a nested one, and ava8 frames read back off disk with the cursor required to cross the staff |
The frozen API surface each package was built to is rispecs/ABSORPTION-CONTRACT.md;
what this package did with it, and where it went beyond it, is
rispecs/ava8-umbrella.spec.md. Both ship in the tarball.
Three runnable consumers live in example/ — a node script that composes a
cosmology which is not Chaosophia, a framework-free browser page, and a React
component. example/README.md has the commands.
What the Ava8 symphony is
Ava8 (shipping as ChaoSophia) is a musical cosmology: a set of glyphs, each one carrying a name, an emoji, a colour, a note sequence and an ABC-notation tune. The prototype shipped four — spiral, puzzle, feather, crystal — with five instruments and per-glyph timbre overrides. A glyph can be rendered as a score, played through a soundfont, exported as MIDI, or sequenced with its siblings into a multi-movement symphony.
In the prototype that knowledge was fused to three things it does not need: Next.js
(next/script), the shadcn/radix UI kit, and the four Ava8 glyphs themselves. The
absorption follows that seam. The cosmology is now the default, not the design —
loadMusicData() replaces it with any other set of glyphs, so a consumer writing an
unrelated composition can take the ABC rendering without inheriting a Next.js app or
the Chaosophia glyphs.
The four-package map
| Package | Runs where | Depends on | Take it when you want |
| --- | --- | --- | --- |
| @miadi/ava8-core | node + browser, no DOM, no React | midi-writer-js | ABC text model, ABC→notes transcription, notes↔MIDI, instruments, tempo, glyph cosmology, symphony composition — with no rendering and no audio |
| @miadi/ava8-abcjs | browser + node (jsdom) | ava8-core, abcjs | score rendering, standalone SVG export and soundfont playback, driven from your own UI or from none |
| @miadi/ava8-react | React 18/19 | ava8-core, ava8-abcjs, react (peer) | components and hooks that already own the render/play lifecycle — no radix, no tailwind |
| @miadi/ava8 | node + browser | all three | one import for everything, plus the ava8 CLI and the standalone symphony server |
Rules the split enforces:
ava8-coretouches nowindowand nodocument. A test walks the built output and fails if a DOM global appears in it.ava8-abcjsreturns bytes and DOM nodes; it never returns React.ava8-reactdeclaresreact/react-domas peerDependencies, so a node-only consumer of@miadi/ava8never installs React. React lives behind the./reactsubpath export.
Install
# everything, including the CLI and the server
npm i @miadi/ava8
# or: pnpm add @miadi/ava8 / yarn add @miadi/ava8
# or take only the layer you need
npm i @miadi/ava8-core # headless music model
npm i @miadi/ava8-abcjs # + rendering and playback
npm i @miadi/ava8-react react react-dom # + components and hooksSubpath exports of @miadi/ava8:
"." -> re-export of ava8-core + ava8-abcjs
"./react" -> re-export of ava8-react (keeps React out of node consumers)
"./server" -> createAva8Server
"./package.json"Start here
1. Node script
Composes a symphony from the loaded cosmology and writes ABC and MIDI to disk. Every
symbol here is exported from the built @miadi/ava8-core.
import { writeFile } from 'node:fs/promises'
import {
buildSymphony, symphonyToAbc, symphonyDurationBeats,
getGlyph, abcBodyToNotes, notesToMidiBytes, instrumentMidiProgram,
parseAbcHeaders, validateAbc, tempoName,
} from '@miadi/ava8-core'
const symphony = buildSymphony({ title: 'Ava8', tempo: 108 })
const abc = symphonyToAbc(symphony) // multi-tune ABC, X: renumbered 1..n
symphonyDurationBeats(symphony) // 29
tempoName(108) // 'Moderato'
parseAbcHeaders(abc).title // 'Ava8 — The Spiral Awakens'
validateAbc(abc).valid // true
const spiral = getGlyph('spiral')
const bytes = await notesToMidiBytes(spiral.notes, { // async since 0.2.0
tempo: 108,
program: instrumentMidiProgram('triangle'), // 73, GM Flute
})
await writeFile('ava8.abc', abc)
await writeFile('spiral.mid', bytes) // Uint8Array, never a BlobA glyph carries its melody twice — as ABC text and as a notes array — and until
0.2.0 nothing checked the two agreed. abcBodyToNotes derives the second from the
first, with the tune's real rhythm rather than one flat note length:
abcBodyToNotes(spiral.abcNotation)
// [ { pitch: 'C4', duration: '4' }, { pitch: 'Eb4', duration: '4' }, … ]
await notesToMidiBytes(abcBodyToNotes(spiral.abcNotation), { tempo: 108 })The first thing it found was a disagreement in the shipped cosmology itself.
spiral is K:Cmin and its notes array spells the thirds E4 — a natural,
under a key signature carrying three flats. Read as notation the tune has an
Eb4 there, and the two spellings have differed since the prototype.
applyKeySignature: false transcribes letter-for-letter and reproduces the array
as shipped; the default reads the notation as written. Neither is guessed at:
abcBodyToNotes(spiral.abcNotation, { applyKeySignature: false }).map((n) => n.pitch)
// ['C4', 'E4', 'G4', 'C5', 'G4', 'E4', 'C4'] — the notes array, exactlySwapping the cosmology is one call — this is what makes the library usable for a composition that has nothing to do with Chaosophia:
import { loadMusicData, resetMusicData, glyphIds, instruments } from '@miadi/ava8-core'
loadMusicData({ glyphs: { /* your glyphs */ }, instruments: { /* yours */ }, glyphInstrumentSettings: {} })
glyphIds() // your ids
instruments // your registry — an ESM live binding, already updated
resetMusicData() // back to spiral / puzzle / feather / crystal2. Vanilla browser page
Plain ESM, no framework. Renders a score, plays it, follows it with a cursor.
<div id="score"></div>
<button id="play">Play</button>
<style>.ava8-playing, .ava8-playing * { fill: #5b4bdb; stroke: #5b4bdb; }</style>
<script type="module">
import { getGlyph, renderScore, createPlayer, unlockAudio } from '@miadi/ava8'
const abc = getGlyph('spiral').abcNotation
const { visualObj } = await renderScore(document.getElementById('score'), abc, { responsive: true })
const player = await createPlayer(abc, {
instrumentId: 'triangle', tempo: 108,
visualObj, // reuse the engraved tune — see below
onEvent: (event) => { /* event.elements are the notes sounding right now */ },
})
document.getElementById('play').onclick = async () => {
await unlockAudio() // iOS needs a gesture before audio starts
player.isPlaying ? player.stop() : await player.play()
}
</script>renderScore clears the container before drawing. createPlayer returns an
Ava8Player with play(), stop(), pause(), resume(), seek(position, units),
setVolume(0..1), destroy(), setTempo(bpm), and the readonly isPlaying,
durationSeconds, currentMs and volume. stopAllAudio() is the process-wide
teardown.
Handing visualObj back is what makes a cursor possible: without it createPlayer
parses the ABC a second time into a throwaway target, and every timing event
addresses real nodes that are not on the page — so highlighting them paints nothing.
loadAbcjs(source) / setDefaultAbcjsSource(source) choose how abcjs arrives —
{strategy:'module'} (default), {strategy:'global'}, {strategy:'cdn', url?}, or
{strategy:'inject', abcjs} for tests and SSR. Concurrent loadAbcjs() calls share one
in-flight promise.
This page ships as example/vanilla/index.html + example/vanilla/app.js, with the
cursor, a seek bar, a volume slider and a MIDI download. There is no bundler anywhere:
it reaches the built package through an import map, and takes abcjs from the pinned
CDN build.
Browser, without a bundler — what the import map needs
A bundler resolves all of this for you. A plain page resolves it itself, and the entries are not equally urgent:
<script type="importmap">
{
"imports": {
"@miadi/ava8": "/node_modules/@miadi/ava8/dist/index.js",
"@miadi/ava8-core": "/node_modules/@miadi/ava8-core/dist/index.js",
"@miadi/ava8-abcjs": "/node_modules/@miadi/ava8-abcjs/dist/index.js",
"midi-writer-js": "https://cdn.jsdelivr.net/npm/[email protected]/+esm"
}
}
</script>- The umbrella and its two siblings are what
@miadi/ava8re-exports, so the browser resolves all three when the page imports the package by name. Engraving, playback, the cursor, transport and volume all work with exactly these three — abcjs is not mapped, becausesetDefaultAbcjsSource({strategy:'cdn'})makes the package fetch its own pinned build. midi-writer-jsis needed bynotesToMidiBytesand nothing else. Core reaches its CommonJS dependency through a dynamicimport(), so an absent mapping surfaces the first time MIDI bytes are asked for — asMidiWriterUnavailableError, which names the specifier and quotes the line above. Leave it out and the score still engraves and plays; only the export fails. The CDN copy is verified against[email protected];/node_modules/midi-writer-js/build/index.browser.jsis the offline equivalent.
Where the sibling packages sit under node_modules is the installer's decision — npm
and yarn hoist, pnpm nests — and an import map has one target per specifier and no
fallbacks. The shipped example therefore lists its candidate paths, probes them with
fetch, and writes the map before loading app.js, so the same two files work under
either installer. Serve, over http, the directory that contains node_modules:
npx http-server . -p 8081
# http://127.0.0.1:8081/node_modules/@miadi/ava8/example/vanilla/index.htmlIf a package path cannot be reached the page says which specifier failed and every path
it tried, and labels the CDN-only staff it draws instead as not @miadi/ava8.
3. React app
import { Ava8Provider, AbcPlayer, useAbcPlayer, useGlyphs } from '@miadi/ava8/react'
import { getGlyph } from '@miadi/ava8'
export default function App() {
return (
<Ava8Provider soundFontUrl="https://paulrosen.github.io/midi-js-soundfonts/FluidR3_GM/">
<AbcPlayer abc={getGlyph('spiral').abcNotation} instrumentId="triangle" tempo={108} showScore />
</Ava8Provider>
)
}AbcPlayer accepts a render-prop child receiving UseAbcPlayerResult, so the transport
UI stays yours. Drop to the hooks when you want to draw every control yourself:
const {
play, stop, toggle, pause, resume, seek, setVolume,
isPlaying, isPaused, durationSeconds, currentMs, volume, error, ready,
} = useAbcPlayer(abc, { tempo: 108 })
const { glyph, glyphId, setGlyph, next, prev, ids } = useGlyphs('spiral')The playback cursor is on by default: the sounding notes carry
highlightClassName (default ava8-cursor), and since this package ships no CSS,
one rule in your stylesheet is what turns it on. currentMs is a ref-backed
getter rather than state — abcjs fires timing on every animation frame, and
publishing that as state would re-render the host subtree sixty times a second.
AbcScore renders without playback; useAbcScore(ref, abc, opts) does the same against
an element you own; useAbcExport(result) turns what is on screen into a standalone SVG
string, a blob, or a print job. Component modules ship "use client", so Next.js App
Router consumers import them unchanged. Every effect tears its player down on unmount
and on abc change.
This component ships as example/react/App.tsx.
CLI
Bin name ava8, compiled to dist/bin.js.
| Verb | Does |
| --- | --- |
| ava8 glyphs | list the cosmology |
| ava8 render <file.abc\|-> [-o out.svg] | headless standalone SVG via jsdom |
| ava8 tunebook <file.abc\|-> -o <dir> | one SVG per tune of a multi-tune file |
| ava8 frames <file.abc\|-> -o <dir> | one SVG per frame, cursor on the sounding note |
| ava8 midi <file.abc\|-> [-o out.mid] | ABC → MIDI |
| ava8 export <glyph> [-o out.mid] | glyph melody → MIDI |
| ava8 symphony [--glyphs a,b,c] [--tempo N] [--title T] [-o out.abc] | compose a multi-movement score |
| ava8 serve [--port 8480] [--data <dir>] | standalone symphony server |
| ava8 init [dir] [--force] | write music-data.json (chaosophia parity) |
| ava8 info <file.abc\|-> | parsed headers + validation report |
| --help / --version | |
A - file argument reads stdin; omitting -o writes stdout. Validation errors exit
non-zero with the reason on stderr. An unknown flag is an error — nothing is
accepted and then discarded.
ava8 glyphs
ava8 render tune.abc -o tune.svg
ava8 render tune.abc --no-responsive --scale 1.5 --staff-width 900 -o big.svg
ava8 tunebook symphony.abc -o ./score # tune-1.svg, tune-2.svg, …
ava8 frames tune.abc -o ./frames --fps 30 # frame-001.svg, frame-002.svg, …
cat tune.abc | ava8 midi - > tune.mid
ava8 symphony --glyphs spiral,puzzle --tempo 120 --title Probe -o probe.abc
ava8 serve --port 8480 --data ./melodiesEngraving flags — render, tunebook and frames
| Flag | Does |
| --- | --- |
| --scale <n> | engraving scale. abcjs ignores it while responsive is on, so pass --no-responsive too |
| --staff-width <n> | engraved staff width in points, default 740 |
| --responsive / --no-responsive | size from a container instead of the staff width. Off by default for a file: a string has no container to be responsive to |
| --padding <n> | engraving margin on all four sides |
| --print | page layout rather than screen layout |
| --no-standalone | omit the width/height a detached <svg> needs. Only for markup you are re-parenting into a container you styled yourself |
| --tune-index <n> | which tune of a multi-tune file to engrave, 0-based |
| --selection-color <css> | note highlight. abcjs hard-codes #ff0000 otherwise — the worst possible colour on a dark theme |
| --all-tunes | on render, behave as tunebook. Needs -o <dir> |
ava8 render on a file holding more than one tune engraves one and says so on
stderr. ava8 info reports the count under Tunes:. symphonyToAbc emits
multi-tune ABC by design, so this is the common case, not the exotic one.
Frame sequences — ava8 frames
tunebook splits one ABC file across space: one tune, one file. frames
splits the same file across time: one moment, one file, with the note
sounding at that moment carrying a class you can style. The contract is the same
one — -o <dir> is required, the directory is created, and the names are
zero-padded to the digit width of the count, so frame-001.svg sorts and globs
in order.
ava8 frames tune.abc -o ./frames # the whole tune, 30 fps
ava8 frames tune.abc -o ./frames --fps 12 --tempo 84 # slower reading, fewer files
ava8 frames tune.abc -o ./lead-in --from 0 --to 2000 # one window of it
ava8 frames tune.abc -o ./frames --cursor-class tide # your class, your stylesheet| Flag | Does |
| --- | --- |
| --fps <n> | frames per second, a whole number from 1 to 120. Default 30 |
| --from <ms> | moment of the first frame. Default 0 |
| --to <ms> | stop here instead of at the end of the tune. The window is [from, to) |
| --cursor-class <c> | class written on the sounding note. Default ava8-cursor, the same class @miadi/ava8-react's cursor uses |
| --tempo <bpm> | tempo the timeline is computed at. Omit it and the tune's own Q: decides |
The frame count comes from the tune: scoreTimings is asked where the score
ends, and that is the same arithmetic the cursor lands on, so the last frame of a
default run is the last moment the score has. Each frame's moment is computed
from its own index rather than by adding an interval to the previous one — over
2700 frames an accumulated 1000 / fps drifts far enough to light the wrong
note. A moment before the first note or past the last one is a frame with no
cursor on it, not an error: a film's lead-in and tail are legitimate frames of
the same score.
No CSS ships with the frames. One rule of your own dresses them, and dresses a live cursor in a browser at the same time:
.ava8-cursor { fill: #e26d5c; stroke: #e26d5c; }frames writes SVG and stops there — exactly as render and tunebook do.
There is no rasteriser, no encoder and no headless browser in this package.
Turning a frame directory into a video is ffmpeg's work, and it belongs to
whoever is making the film:
ava8 frames tune.abc -o ./frames --fps 30
# SVG is vector; rasterise first unless your ffmpeg was built with librsvg.
for f in ./frames/*.svg; do rsvg-convert -w 1920 "$f" -o "${f%.svg}.png"; done
ffmpeg -framerate 30 -i ./frames/frame-%03d.png -pix_fmt yuv420p tune.mp4Keeping SVG as the master is the point of stopping here: the frames are re-encodable at any size, and a package that starts shelling out to encoders has stopped being a music package.
Two flags reach every verb: --json for machine-readable output where it makes sense
(glyphs, info, symphony), and --music-data <file> — also honoured as
MUSIC_DATA_PATH — to run the whole CLI against a different cosmology. The prototype
set that variable for next start alone.
The prototype's exploratory bin/chaosophia.js verbs start/build/prepare/init
are superseded. serve replaces start without a Next.js build in the consumer's
working directory — that substitution is the point of the absorption.
Standalone server
createAva8Server(opts), exported from @miadi/ava8/server. Plain node http, no
Next.js.
| Route | Response |
| --- | --- |
| GET /api/melodies | list, seeding the glyph cosmology on first call |
| POST /api/melodies | {name, abc} save; 400 when either is missing |
| GET /api/glyphs | the cosmology |
| GET /api/health | {ok:true, store:'file'\|'memory'\|'upstash', glyphs:n} |
| GET / | a self-contained player page consuming the bundle over ?abc= |
The player page has a full transport — play, pause/resume, stop, a seek bar and a
volume slider — and follows the tune with a cursor. Its render parameters are
abcjsRenderParams() serialised into the page rather than restated there, so the
page, ava8 render and @miadi/ava8-react engrave the same ABC the same way.
Storage is an interface, not a vendor:
export interface Melody { name: string; abc: string; updatedAt?: string }
export interface MelodyStore {
list(): Promise<Melody[]>
get(name: string): Promise<Melody | null>
save(melody: Melody): Promise<void>
remove(name: string): Promise<void>
}
export function memoryStore(seed?: Melody[]): MelodyStore
export function fileStore(dir: string): MelodyStore
export function upstashStore(opts: { url: string; token: string }): MelodyStoreThe server picks upstashStore when MELO_UPSTASH_REDIS_REST_URL and
MELO_UPSTASH_REDIS_REST_TOKEN are both non-empty, otherwise fileStore when a data
directory is given, otherwise memoryStore. The prototype's app/api/melodies/route.ts
constructed its Redis client from those two env vars with an empty-string fallback and
turned every resulting failure into HTTP 500, so the composer could not run without a
Redis account. upstashStore is an optional dependency here; no store is required.
import { createAva8Server } from '@miadi/ava8/server'
const server = createAva8Server({ dataDir: './melodies' })
await server.listen(8480)
server.storeKind // 'file'
server.url // 'http://127.0.0.1:8480' — loopback, not 0.0.0.0
await server.close()fileStore writes one JSON file per melody and never lets a melody name become a path:
the file name is a slug plus a truncated SHA-256 of the original name, so a melody called
../../secrets.txt lands inside the data directory like any other. upstashStore keeps
the prototype's melody:<name> keys, refuses empty credentials at construction, and
imports @upstash/redis lazily — absent, it raises MissingUpstashError naming the
specifier instead of a blanket 500.
Absorbed from
Source prototype: jgwill/Ava8@3cf080a (branch main), plus the CLI exploration on
origin/codex/explore-creating-distributable-package-with-cli-2025-06-21-22-18-57
(bin/chaosophia.js, SPECIFICATIONS.md). The prototype is read only — nothing in
these packages writes back to it.
Rendering existed twice in the prototype and the two copies disagreed:
components/abc-notation-renderer.tsx loaded abcjs from a jsdelivr <Script> pinned at
6.2.2, components/music-notation.tsx dynamic-imported the npm module (declared
"abcjs": "latest"). The absorption reconciles them into one renderer with a pluggable
loader strategy.
Four defects found in the prototype are closed on the way in: noteToMidiNumber
throwing instead of returning NaN, the playback end-timer being cleared on stop, the
iOS audio-unlock listeners no longer accumulating, and melody storage no longer
answering a missing credential, a malformed melody and a network outage with the same
HTTP 500.
Moving the jgwill/Ava8 app itself onto these packages, file by file:
rispecs/MIGRATION-from-Ava8-app.md.
Documents
Every one of these ships inside the published tarball.
| Document | Holds |
| --- | --- |
| rispecs/ABSORPTION-CONTRACT.md | the four-package API surface and the testing bar |
| rispecs/ava8-umbrella.spec.md | why this package exists: the distribution the prototype had no way to offer |
| rispecs/MIGRATION-from-Ava8-app.md | prototype file → replacement import, one row per file |
| example/README.md | how to run the three consumers |
The three sibling READMEs are on npm:
@miadi/ava8-core,
@miadi/ava8-abcjs,
@miadi/ava8-react.
Build and test
npm run build # tsc -p tsconfig.build.json, strict
npm test # build, then node --test test/*.test.mjsEvery CLI verb is exercised as a real child process, with assertions on exit codes and
on the bytes that reach stdout and disk. The server is driven over real HTTP. The
storage layer runs against a real filesystem. example/node/compose.mjs is run as a
consumer and its output files are parsed back.
License
MIT for the packaging. The prototype's own content is CC BY-NC 4.0, authored by Guillaume Descoteaux-Isabelle, with contributions from Gérico Tremblay.
