@miadi/ava8-core
v0.4.2
Published
Headless ABC-notation music core absorbed from jgwill/Ava8: ABC text model, ABC->notes transcription, notes<->MIDI, instruments, tempo, a swappable glyph cosmology and symphony composition. ESM only. No DOM, no React.
Readme
@miadi/ava8-core
The headless music layer of Ava8 — ABC notation text model, ABC→notes transcription, notes↔MIDI, an instrument registry, tempo naming, a swappable glyph cosmology, and symphony composition.
No window, no document, no React, no abcjs. One runtime dependency:
midi-writer-js, loaded lazily so that importing this package touches nothing
outside it.
Rendering and playback live one layer up in
@miadi/ava8-abcjs;
components live in
@miadi/ava8-react. This
package is the part that has no opinion about where it runs.
In the Miadi stack, the ava8 packages are the music layer of the studio: they write ABC notation, render and play scores, and check a rendered piece against what it claimed. They are groundwork for the musical composition side of Miadi's relational productions (film and performance), which is not built yet.
0.2.0
Breaking: notesToMidiBytes is now async. It returns
Promise<Uint8Array>; await it, or chain .then().
-const bytes = notesToMidiBytes(notes, { tempo: 84 })
+const bytes = await notesToMidiBytes(notes, { tempo: 84 })[email protected] is pure CommonJS — its build/index.js ends in
module.exports = main and declares no ESM export, even though the package's
exports map serves that same file under the import condition. Statically
importing it made this whole package fail to link in a browser and in an
ESM-first bundler, with does not provide an export named 'default', before a
line of code ran. 0.1.0's claim that it "imports cleanly … in a browser" was
false, and it broke @miadi/ava8-abcjs transitively.
0.2.0 moves that import inside the one function that needs it, which is what
makes the function async. There is no notesToMidiBytesSync: a synchronous
path would need either a top-level import (the bug) or node:module's
createRequire (a node-only import in a package that must run in a browser).
Also new in 0.2.0:
notesToMidiBytestakes chords, rests and per-note durations and velocities — see Notes and MIDI. Everystring[]that worked in 0.1.0 still works and still produces the same bytes.abcBodyToNotestranscribes an ABC tune into those note specs, so a melody is written once instead of twice.UnsupportedAbcError,NoteSpec,AbcNotesOptions,abcKeyAccidentals.
ESM only
This package is "type": "module" and ships no CommonJS build. Its
exports map declares an import condition and nothing else.
require('@miadi/ava8-core')fails withERR_PACKAGE_PATH_NOT_EXPORTED.- Under TypeScript's
moduleResolution: node16/nodenext, importing it from a CommonJS file fails at compile time with TS1479 ("the current file is a CommonJS module") and, if you reach for a top-levelawait, TS1541.
To consume it you need one of: a bundler (Vite, esbuild, Rollup, webpack 5,
Next.js), "type": "module" in your own package.json, an .mjs file
extension, or a runtime-only await import('@miadi/ava8-core') from CommonJS.
Node 18.17 or newer. Browsers: any that support ES modules — with one extra import-map entry that only MIDI export needs, below.
Install
npm i @miadi/ava8-core
# or: npm i @miadi/ava8-core / yarn add @miadi/ava8-coreBrowser, without a bundler
A bundler (Vite, webpack, esbuild, Next.js) resolves everything this package reaches for, and there is nothing to configure. A plain page with an import map has to resolve two specifiers itself, and they are not equally urgent:
<script type="importmap">
{
"imports": {
"@miadi/ava8-core": "/node_modules/@miadi/ava8-core/dist/index.js",
"midi-writer-js": "https://cdn.jsdelivr.net/npm/[email protected]/+esm"
}
}
</script>The package itself is the only entry you need to import it and use it. The
ABC model, transcription, notes, pitches, instruments, tempo, glyphs and
symphony composition all run with nothing else mapped — engraving and playback,
one layer up in @miadi/ava8-abcjs, need nothing else either.
midi-writer-js is needed by exactly one function. notesToMidiBytes
reaches its CommonJS dependency through a dynamic import() — deliberately, so
that a CommonJS module never enters this package's ESM graph (see Why
notesToMidiBytes is async). That defers the resolution to the moment MIDI
bytes are asked for, so a missing mapping is not a load error but a call-time
one:
await notesToMidiBytes(['C4'])
// MidiWriterUnavailableError: midi-writer-js could not be loaded:
// Failed to resolve module specifier 'midi-writer-js'. notesToMidiBytes resolves it
// with a dynamic import() at call time, so it must be resolvable from @miadi/ava8-core.
// A bundler does this for you; a browser page with no bundler needs the specifier in
// its import map: "midi-writer-js": "https://cdn.jsdelivr.net/npm/[email protected]/+esm"The CDN copy above is verified against [email protected]. To stay offline,
point at the installed one instead — build/index.browser.js is the ESM build
in the same tarball:
"midi-writer-js": "/node_modules/midi-writer-js/build/index.browser.js"Where that file sits under node_modules depends on your installer: npm and
yarn hoist it to the root, pnpm nests it under the package that depends on it.
example/vanilla/index.html in @miadi/ava8 probes both before it writes its
map, and is the working page to copy from.
The swappable cosmology
The prototype's music knowledge was fused to four glyphs named spiral, puzzle, feather and crystal. Here that data is the default, not the design:
import { loadMusicData, resetMusicData, glyphIds, instruments } from '@miadi/ava8-core'
loadMusicData({
glyphs: {
ebb: { name: 'Ebb', emoji: '🌊', color: '#0ea5e9', description: 'Water withdrawing',
notes: ['D4', 'A3', 'F3'], abcNotation: 'X:1\nT:Ebb\nM:3/4\nL:1/8\nK:Dm\nD2A,2F,2|' },
flood: { name: 'Flood', emoji: '🌕', color: '#f97316', description: 'Water returning',
notes: ['F3', 'A3', 'D4'], abcNotation: 'X:1\nT:Flood\nM:3/4\nL:1/8\nK:Dm\nF,2A,2D2|' },
},
instruments: { reed: { id: 'reed', name: 'Reed', icon: '🎋', description: 'A single reed',
oscillatorType: 'sawtooth',
envelope: { attack: 0.1, decay: 0.2, sustain: 0.8, release: 0.4 } } },
glyphInstrumentSettings: { ebb: { oscillatorType: 'triangle' } },
})
glyphIds() // ['ebb', 'flood']
instruments.map((i) => i.id) // ['reed']
resetMusicData()
glyphIds() // ['spiral', 'puzzle', 'feather', 'crystal']loadMusicData copies what you give it, so mutating your object afterwards
changes nothing here. A load that cannot be used throws InvalidMusicDataError
and leaves the previous cosmology in force.
How instruments stays reactive — an ESM live binding
Everything derived from the cosmology is read at call time, never frozen at module load:
| Export | Mechanism |
| --- | --- |
| glyphs(), getGlyph(), glyphIds(), nextGlyph(), glyphInstrumentSettings() | accessor functions — they read the store on every call |
| instruments | an ESM live binding (export let, reassigned when the cosmology changes) |
import { instruments } binds the export cell rather than copying the array, so
after loadMusicData() the same imported identifier already points at the new
registry — no re-import, no getter call. Namespace access
(const api = await import('@miadi/ava8-core'); api.instruments) is live for the
same reason: ESM namespace properties are getters. Because imported bindings are
read-only, consumers cannot assign to it.
ABC text model — src/abc.ts
import { parseAbcHeaders, setAbcHeader, validateAbc, splitAbcTunes, joinAbcTunes } from '@miadi/ava8-core'
const tune = 'X:1\nT:The Spiral Awakens\nM:4/4\nL:1/8\nK:Cmin\nC2E2G2c2|G2E2C4|'
parseAbcHeaders(tune)
// { index: '1', title: 'The Spiral Awakens', meter: '4/4', unitNoteLength: '1/8', key: 'Cmin' }
setAbcHeader(tune, 'Q', '84')
// 'X:1\nT:The Spiral Awakens\nM:4/4\nL:1/8\nQ:84\nK:Cmin\nC2E2G2c2|G2E2C4|'
// — inserted at its canonical rank, always before K:
validateAbc('X:1\nT:Keyless\nM:4/4')
// { valid: false, errors: ['Missing K: key header', ...], warnings: [] }
joinAbcTunes(splitAbcTunes(multiTuneFile)) // round-trips, X: renumbered 1..nvalidateAbc is structural only — no abcjs, no music parsing. Blocking
errors: no X:, no K:, tune body before K:, empty body. Non-blocking
warnings: unknown header letters, missing T:, missing M:.
Q: is read best-effort from all three notations — Q:120, Q:1/4=96,
Q:"Allegro" 1/4=168.
ABC → notes — src/abc-notes.ts
abcBodyToNotes is the path between this package's two representations of the
same melody. Before it, a consumer wrote every tune twice — once as ABC text for
the score, once as a string[] for MIDI — with nothing checking the two agreed.
import { abcBodyToNotes, notesToMidiBytes, UnsupportedAbcError } from '@miadi/ava8-core'
abcBodyToNotes('X:1\nM:4/4\nL:1/8\nK:C\nC2E2G2c2|')
// [ { pitch: 'C4', duration: '4' }, { pitch: 'E4', duration: '4' },
// { pitch: 'G4', duration: '4' }, { pitch: 'C5', duration: '4' } ]
abcBodyToNotes('X:1\nL:1/8\nK:D\n[DFA]2 z2 d4-d4|')
// [ { pitch: ['D4', 'F#4', 'A4'], duration: '4' }, — a chord; K:D sharpened the F
// { pitch: [], duration: '4', rest: true }, — a rest
// { pitch: 'D5', duration: '1' } ] — the tie merged two halves
const bytes = await notesToMidiBytes(abcBodyToNotes(tune), { tempo: 84 })It accepts a whole tune or a bare body. K: supplies the accidentals and L:
the unit note length — falling back to M: and then to 1/8, as ABC v2.1
specifies.
Transcribed: note letters with octave marks (C is middle C, c is C5, C, is
C3, c' is C6), accidentals ^ _ = ^^ __ with measure-long persistence,
lengths C2 C/2 C/ C3/2, rests z and x, chords [CEG] and [CEG]2,
ties between identical pitches, barlines and repeat marks as separators, slurs,
decorations, "..." annotations, w: lyric lines.
Anything else raises UnsupportedAbcError, whose construct names what was
found — grace notes, tuplets, broken rhythm, multi-measure rests, voice
overlays, inline [K:…], a body K:/L:/M:/V:/P: line. It refuses
rather than guessing, because a dropped grace note or a mis-read tuplet changes
the music without saying so.
Two things it does not do: repeats are read as separators, not expanded; and
Q: is not consulted — tempo is a render option, not a transcription.
abcBodyToNotes(abc, { applyKeySignature: false }) transcribes letter for
letter. That is how the shipped cosmology spells its notes arrays — naturals
even under K:Cmin and K:D.
What the shipped cosmology's own data says
src/data/music-data.json is absorbed verbatim from the prototype, where each
glyph's abcNotation and notes array were written by hand and never compared.
Transcribing the first and diffing it against the second is the first check they
have ever had, and three of the four disagree. The test suite asserts these
divergences exactly rather than smoothing them over — they are facts about the
data, not about the transcriber:
| Glyph | notes vs. its own abcNotation |
| --- | --- |
| spiral | agree, note for note |
| puzzle | the array sits one octave above the ABC (G, is G3, listed as G4) |
| feather | the same octave shift |
| crystal | same octave, but note 5 is listed as F5 where the ABC writes F |
All four arrays spell naturals, so none of them carries its tune's key
signature: K:Cmin flattens spiral's E, K:D sharpens crystal's F, K:A
sharpens feather's C.
When each note sounds — src/timeline.ts
abcBodyToNotes computes every duration exactly, in ticks, and then names each
count as a midi-writer-js token — the right answer for a MIDI writer, and no
answer at all for anything that has to know when. noteTimeline reads the same
ticks through the same parse and states them as milliseconds:
import { noteTimeline, timelineDurationMs, noteAtMs } from '@miadi/ava8-core'
const timeline = noteTimeline('X:1\nM:4/4\nL:1/4\nQ:120\nK:C\nCDEF|')
// [ { index: 0, startMs: 0, durationMs: 500, pitch: ['C4'], rest: false },
// { index: 1, startMs: 500, durationMs: 500, pitch: ['D4'], rest: false },
// { index: 2, startMs: 1000, durationMs: 500, pitch: ['E4'], rest: false },
// { index: 3, startMs: 1500, durationMs: 500, pitch: ['F4'], rest: false } ]
timelineDurationMs(timeline) // 2000
noteAtMs(timeline, 500) // the D — the interval is [startMs, startMs + durationMs)
noteAtMs(timeline, 2000) // null — the piece is overinterface NoteOnset { index: number; startMs: number; durationMs: number; pitch: string[]; rest: boolean }
function noteTimeline(abc: string, opts?: NoteTimelineOptions): NoteOnset[]
function timelineDurationMs(timeline: NoteOnset[]): number
function noteAtMs(timeline: NoteOnset[], ms: number): NoteOnset | nullindexis the index intoabcBodyToNotes(abc, opts). Both functions read the tune through one parse, sotimeline[i]describesnotes[i]and the two lengths are equal for every input — by construction, not by coincidence.- Tempo resolution:
opts.tempo, then the tune's ownQ:, thenava8Config.defaults.tempo(84), folded throughclampTempo.NoteTimelineOptionsextendsAbcNotesOptions, soapplyKeySignatureworks here too. - Rests are entries —
rest: true,pitch: [], a realdurationMs. A millisecond inside a rest returns the rest, because silence is something the piece is doing. - A chord is one entry carrying every sounding pitch, at one duration.
- A tie is one entry whose
durationMscovers both halves, and the note after it starts when the tie ends. noteAtMsis a binary search, because an animation asks it once per frame.nullbefore the first onset and at or after the end; a non-finitemsis aTypeErrorrather than a silent "the piece is over".
Onsets are computed from each note's cumulative tick offset, never by
summing the previous durationMs values. Ticks are exact; milliseconds at an
arbitrary tempo are not, and a running sum carries every rounding forward until
the cursor lands on the wrong note near the end of a long piece. Over 240 eighth
notes at 84 bpm the two methods already differ, and the drifted value selects
the note before the one that sounds — the suite asserts that difference rather
than describing it.
Whatever abcBodyToNotes refuses, this refuses identically: UnsupportedAbcError
naming the construct, InvalidNoteError, TypeError. Nothing is caught and
degraded into a timeline missing a sound.
Notes and MIDI — src/notes.ts, src/midi.ts
import { noteToMidiNumber, midiNumberToNote, transposeNote, noteToFrequency,
notesToMidiBytes, InvalidNoteError } from '@miadi/ava8-core'
noteToMidiNumber('C4') // 60
noteToMidiNumber('C#4') // 61
noteToMidiNumber('Bb3') // 58 — flats, ♭/♯, double sharps, negative octaves
midiNumberToNote(58) // 'A#3' — sharps spelling
transposeNote('Bb3', 2) // 'C4'
noteToFrequency('A4') // 440
noteToMidiNumber('H9') // throws InvalidNoteError { value: 'H9' }
const bytes = await notesToMidiBytes(['C4', 'E4', 'G4', 'C5'], { tempo: 84, program: 10 })
bytes instanceof Uint8Array // true — never a Blob
[...bytes.slice(0, 4)] // [0x4d, 0x54, 0x68, 0x64] — 'MThd'A sequence entry is either a note name or a NoteSpec:
type NoteSpec =
| string
| { pitch: string | string[]; duration?: string; velocity?: number; rest?: boolean }
function notesToMidiBytes(notes: NoteSpec[], opts?: MidiRenderOptions): Promise<Uint8Array>pitch: string[]is a chord — one event, every pitch sounding together.rest: trueis a rest ofduration(passpitch: []). Consecutive rests add up; a trailing rest has no note left to delay and changes nothing.durationandvelocityoverrideMidiRenderOptionsfor that event only.
await notesToMidiBytes([
{ pitch: ['C4', 'E4', 'G4'], duration: '2' }, // a half-note chord
{ pitch: [], duration: '4', rest: true }, // a quarter rest
'A4', // opts.duration, opts.velocity
{ pitch: ['D4', 'F4'], duration: '8', velocity: 40 },
])Duration tokens are midi-writer-js': '1' whole, '2' half, '4' quarter,
'8' eighth, 'd4' dotted quarter, 'T96' an explicit 96 ticks at 128 ticks
per quarter note. abcBodyToNotes emits exactly these.
Writing the file is the caller's business: fs.writeFile(path, bytes) in node,
new Blob([bytes], { type: 'audio/midi' }) in a browser. Core does not touch
document or URL.createObjectURL.
A take as a file, a phrase on a grid, a chord as pitches — src/midi-events.ts, src/quantise.ts, src/chords.ts
Pure functions, no dependency, nothing loaded.
import { eventsToMidiBytes, notesToMidiEvents, quantiseNotes, parseChordSymbol, chordToAbc } from '@miadi/ava8-core'
// A played take: events at milliseconds, the sustain pedal among them. Format 0, TPQ 480, 120 BPM.
const bytes = eventsToMidiBytes([
{ type: 'noteOn', note: 60, velocity: 100, time: 0 },
{ type: 'cc', controller: 64, value: 127, time: 1000 },
{ type: 'noteOff', note: 60, time: 1000 },
])
// A sung phrase: transcribed notes (seconds), snapped to sixteenths at 92 BPM, as a file.
const aligned = quantiseNotes(notes, { bpm: 92 }) // a note never shorter than a step, never overlapping
const take = eventsToMidiBytes(notesToMidiEvents(aligned), { bpm: 92 }) // the file's beat is the grid's
parseChordSymbol('G/B') // { notes: [67, 71, 74], bass: 59 }
chordToAbc('G/B') // a one-bar ABC tune an ABC player soundseventsToMidiBytes(events, { tpq, bpm })writes events in the order given. An event earlier than the one before it is written at the same tick.notesToMidiEvents(notes)gives each note a note-on and a note-off at velocity 100, in time order with a release first, at least 20 ms long. A pitch outside 0 to 127 is dropped.quantiseNotes(notes, { bpm, subdivision = 16 })snaps start and end separately, so a phrase keeps its shape. With no tempo, the notes come back as sung.gridSeconds(bpm, subdivision)is one step.parseChordSymbol(symbol)reads the 30 qualities inCHORD_QUALITIES(triads, sevenths, sixths, ninths, suspended, power chords) and a slash bass. It voices them around middle C and answers null for anything else.
Instruments and tempo — src/instruments.ts, src/tempo.ts
import { getInstrumentById, instrumentMidiProgram, tempoName, clampTempo } from '@miadi/ava8-core'
getInstrumentById('triangle').name // 'Wooden Flute'
getInstrumentById('nonsense').id // 'sine' — falls back to the first instrument
instrumentMidiProgram('triangle') // 73 (GM Flute)
tempoName(59) // 'Largo'
tempoName(60) // 'Adagio'
clampTempo(9999) // 240Glyphs and symphony — src/glyphs.ts, src/symphony.ts
import { getGlyph, nextGlyph, buildSymphony, symphonyToAbc, symphonyDurationBeats } from '@miadi/ava8-core'
getGlyph('spiral').notes // ['C4','E4','G4','C5','G4','E4','C4']
nextGlyph('crystal', 'left') // 'spiral' — wraps in both directions
const symphony = buildSymphony({ title: 'Ava8', tempo: 108 })
symphonyToAbc(symphony) // multi-tune ABC, X: renumbered 1, 2, 3, 4
symphonyDurationBeats(symphony) // 29'left' (a right-to-left swipe) advances, 'right' goes back — the prototype's
gesture semantics, preserved. getGlyph on an unknown id throws
UnknownGlyphError carrying the ids that are available.
Errors
Ava8Error is the base class, so one catch covers the package.
InvalidNoteError (unparseable or out-of-range pitch, carrying value),
UnknownGlyphError (id absent from the loaded cosmology, carrying id and
available), UnsupportedAbcError (an ABC construct abcBodyToNotes will not
guess at, carrying construct and sometimes position),
InvalidMusicDataError (unusable loadMusicData argument) and
MidiWriterUnavailableError (midi-writer-js could not be loaded when
notesToMidiBytes asked for it, carrying specifier and the original failure as
cause) extend it.
