@miadi/ava8
v0.4.0
Published
The Ava8 symphony as a Miadi package — umbrella re-export of the ABC core, abcjs binding, React layer, atelier law and measurement, plus the ava8 CLI (render, tunebook, frames, midi, symphony, measure, verify, serve) and a standalone symphony server. ESM
Readme
@miadi/ava8
The Ava8 symphony, absorbed from jgwill/Ava8@3cf080a into four packages that can
be taken separately, plus two later siblings that read back what the four write.
ESM only. Every one of them 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.4.0
Two packages joined the family and the CLI grew the two verbs that read them. Additive: nothing from 0.3.x changed shape.
| Package | Holds |
| --- | --- |
| @miadi/ava8-atelier | the atelier's law as data — RegisterPlan, parseMode, the STRIDENCE thresholds and the verdicts they produce, the provenance block, the append-only consent ledger |
| @miadi/ava8-measure | reading back — Standard MIDI decoding with register, mode, drum-position and note-for-note measures; spectral share and f0 over PCM; OSC movement analysis |
Both reach the root export as namespaces, atelier and measure:
import { atelier, measure, notesToMidiBytes } from '@miadi/ava8'
const bytes = await notesToMidiBytes(['C5', 'D5', 'E5', 'F5'], { tempo: 96 })
const verdict = measure.verify(bytes, { mode: 'cmajor', noteCount: 4 }, atelier.parseMode)
verdict.ok // true
verdict.checked // ['mode', 'noteCount'] — an empty list is NOT a pass
console.log(measure.formatVerdict(verdict))That is not a second convention, it is a collision. @miadi/ava8-core and
@miadi/ava8-atelier both export GM_DRUMS, DRUM_CHANNEL, BandsOverlap and
WindowTooNarrow, and they disagree about the drum channel deliberately: core's
is 10, the number a musician writes as %%MIDI channel 10, and the atelier's
is 9, the nibble a parser reads off the wire. A name carried by two export *
clauses is ambiguous, and an ambiguous star export is silently unreachable —
so flattening these two in would have made "which channel are the drums on" a
coin toss with no error message. Both packages are zero-dependency and DOM-free,
so admitting them costs the root export nothing it was protecting: no node:*,
no React, no jsdom joins the graph.
New verbs, documented under Reading back below:
ava8 measure render.mid # what is actually in it
ava8 verify render.mid --mode ddorian --kick 0,2,4,6 # exits non-zero when a claim fails0.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 six 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-atelier | built — zero dependencies; a test asserts no band is exported as a default |
| @miadi/ava8-measure | built — zero dependencies beyond the atelier; a hand-written FFT, so nothing is trusted that cannot be audited in one sitting |
| @miadi/ava8 (this package) | built — 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, ava8 frames read back off disk with the cursor required to cross the staff, and ava8 verify asserted to exit non-zero on a false claim and on no claim at all |
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 package map
The first four are the absorption. The last two arrived in 0.4.0 and go the other way: everything above them writes a file, and they read one back.
| 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-atelier | node + browser, nothing at all | nothing | the law as data: named non-overlapping register bands one of which must stay empty, mode parsing and purity, the spectral thresholds, provenance, a consent ledger |
| @miadi/ava8-measure | node + browser, bytes in, never a path | ava8-atelier | to read a rendered artefact back — MIDI notes, registers, mode purity, drum positions, spectral share, f0, movement captures — and hold it to a claim |
| @miadi/ava8 | node + browser | all five | 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 hooks
npm i @miadi/ava8-atelier # the law: bands, modes, thresholds, consent
npm i @miadi/ava8-measure # reading a rendered artefact backSubpath 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 measure <file\|-> | read a rendered artefact — MIDI, WAV or a movement capture — and print what is in it |
| ava8 verify <file\|-> [claims] | hold a rendered MIDI to claims, and exit non-zero when one fails |
| 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.
Reading back — ava8 measure and ava8 verify
Every verb above turns an intention into a file. These two go the other way. A
generator's header states what it meant; at least three tools sit between that
intention and the bytes on disk, and each of them has quietly changed something
at least once. measure prints what a rendered artefact actually contains.
verify holds it to claims and exits non-zero when one fails — that exit
code is the entire product, because a verifier that always exits 0 is a
decoration a build script will run forever without ever reporting anything.
ava8 measure render.mid # notes, registers, tempo map, mode weights, drum grid
ava8 measure take.wav # rate, length, spectral share in 2000-5000 Hz
ava8 measure movement.jsonl # packets, held ratio, rates, onsets, heading, stillness
ava8 measure render.mid --json # the same, as one JSON documentDispatch is on content, never on the extension. MThd is Standard MIDI,
RIFF/WAVE is a wave file, one JSON object a line carrying t and values
is a movement capture. A .mid that is really a WAV is an ordinary accident of
a pipeline with three tools in it, so the header wins and the disagreement is
reported on stderr rather than becoming a parse error deep inside a reader.
ava8 verify render.mid \
--empty 88-96 \
--mode ddorian --min-purity 0.95 \
--tempo 96,136 \
--kick 0,2,4,6 \
--seconds 8.5 --tolerance 0.2 \
--notes 60 \
--source previous.mid| Claim | Holds when |
| --- | --- |
| --empty <LO-HI> | no melodic note falls inside that inclusive MIDI band. Channel 10 is excluded, so a drum kit never raises a false alarm |
| --mode <name> | the sounding duration inside that mode is at least --min-purity. ddorian, "e phrygian", Bb-major, dsdorian for D sharp, or a bare list 2,4,6,7,9,11,1 |
| --min-purity <s> | a share from 0 to 1. Default 1 — exactly pure |
| --tempo <a,b,…> | the file's tempo events, in order, within --tempo-tolerance bpm (default 0.5) |
| --kick <0,2,4,6> | note 36 lands on exactly those eighth-note slots within the bar, and nowhere else |
| --seconds <s> --tolerance <s> | the last note ends there, give or take. Tolerance defaults to 0.5 s, which is tick arithmetic and not a judgement |
| --source <f.mid> | the same material as another render, note-for-note; --multiset forgives the order and nothing else |
| --notes <n> | there are exactly that many notes |
| --json | the whole verdict as JSON on stdout, and nothing else on stdout |
Exit codes, which is what a script reads:
| Code | Means |
| --- | --- |
| 0 | every claim held, and there was at least one |
| 1 | the artefact was read and a claim failed |
| 2 | the command line was wrong, or no claim was made at all |
1 and 2 are deliberately different. "This file does not do what you said"
and "you did not say anything" are opposite situations, and a pipeline that
cannot tell them apart will report a green run for a check that never happened.
Two things that do not bend
The register band is never a default. --empty has no fallback value.
A void band — a register left empty on purpose, so that a person can sing
through it — is a measurement of that person's body. Shipping one as a default
would publish it, and a published version cannot be unpublished after
seventy-two hours. @miadi/ava8-atelier makes the same refusal one layer down
by taking its bands as a required argument. So ava8 verify without --empty
checks no band, and says so on stderr:
ava8: no register band was checked — pass --empty LO-HI to check one. This CLI
ships no default band, because a void band is a measurement of a living
person's voice and a default would publish it.Silence there would read as a pass, which is the failure this sentence exists to prevent. A test in this package reads the compiled verb back and fails if a numeric band edge has grown into it.
Nothing checked is not everything passed. ava8 verify render.mid with no
claim flags prints nothing was claimed, so nothing was checked — this is not a
pass and exits 2. Verdict.ok from @miadi/ava8-measure is true in that
case, and correctly so — no finding failed, because there were none — so the CLI
requires that at least one claim was actually checked before it will report
success. --json carries the same fact as checked: [] and
nothingClaimed: true.
Two flags reach every verb: --json for machine-readable output where it makes sense
(glyphs, info, symphony, measure, verify), 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 sibling READMEs are on npm:
@miadi/ava8-core,
@miadi/ava8-abcjs,
@miadi/ava8-react,
@miadi/ava8-atelier,
@miadi/ava8-measure.
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.
