@miadi/ava8-core
v0.3.1
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.
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: pnpm add @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.
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.
Absorbed from
Source prototype: jgwill/Ava8@3cf080a (branch main). The prototype is read
only — nothing in this package writes back to it.
| Module here | Absorbed from | Change on the way in |
| --- | --- | --- |
| src/data/music-data.json | lib/music-data.json | copied verbatim — 4 glyphs, 5 instruments, 4 glyphInstrumentSettings |
| src/config.ts ava8Config | lib/config.json | values unchanged; typed as Ava8Config instead of a raw JSON import |
| src/config.ts loadMusicData / resetMusicData | (new) | the programmatic form of the CLI's MUSIC_DATA_PATH; the prototype had no way to swap the cosmology |
| src/notes.ts | lib/midi-export.ts noteToMidiNumber | accepts flats/♭/♯/double sharps/negative octaves; throws InvalidNoteError where the prototype returned NaN |
| src/midi.ts notesToMidiBytes | lib/midi-export.ts createMidiFile | returns Uint8Array, not a Blob; async, so the CommonJS dependency stays out of the module graph; chords, rests and per-note rhythm; optional GM program change; tempo clamped |
| — | lib/midi-export.ts downloadMidi / exportGlyphAsMidi | not absorbed — DOM helpers, they belong in ava8-abcjs/ava8-react |
| src/instruments.ts registry | lib/instruments.ts | OscillatorType/BiquadFilterType DOM lib globals replaced with Ava8OscillatorType/string; the array became a live binding instead of a module-load snapshot |
| src/instruments.ts instrumentMidiProgram | components/echo-composer.tsx:251-266 getInstrumentMidiProgram | lifted verbatim out of a React component — it is data, not UI |
| src/tempo.ts tempoName | components/echo-composer.tsx:176-185 formatTempo | same lift; boundary fixed (see below) |
| src/glyphs.ts nextGlyph | lib/music-data.ts getNextGlyph | swipe semantics preserved; reads the loaded cosmology instead of the JSON import |
| src/glyphs.ts accessors | lib/music-data.ts abcNotations/glyphNotes/glyphDescriptions/glyphColors/glyphEmojis/glyphNames | six parallel maps hard-coded to four glyph ids became glyphs() / getGlyph() over any cosmology |
| src/glyphs.ts glyphInstrumentSettings | lib/instruments.ts getGlyphInstrumentSettings | fallback to { oscillatorType: 'sine' } preserved |
| src/abc.ts, src/symphony.ts | (new) | the prototype passed ABC strings around raw and played one glyph at a time |
| src/abc-notes.ts | (new) | the prototype kept each melody's ABC text and note array side by side, unchecked |
The tempo boundary, deliberately changed
formatTempo compared with strict < against each range's max, so 59 bpm read
as "Adagio" even though the config the prototype itself shipped declares
largo: { min: 40, max: 59 }. Every boundary value landed one range too high.
Here each range owns its own max: tempoName(59) === 'Largo',
tempoName(60) === 'Adagio'.
Local typings for midi-writer-js
[email protected] ships .d.ts files but its exports map declares no
types condition, so moduleResolution: NodeNext cannot reach them. Its
main.d.ts also declares an ESM export default while the runtime module ends
in module.exports = main. src/midi-writer-js.d.ts models exactly the surface
src/midi.ts uses, as a type-only import that the compiler erases. Nothing from
it leaks into this package's public .d.ts — notesToMidiBytes returns a plain
Promise<Uint8Array>, and the built dist/ names midi-writer-js in one
place: the dynamic import() inside notesToMidiBytes.
Build and test
npm run build # tsc -p tsconfig.build.json, strict
npm test # build, then node --test test/*.test.mjsThe suite asserts bytes, strings, numbers and thrown errors — MIDI header bytes
and tempo meta-events, note-on/note-off counts and delta times parsed back out
of the emitted file, every MIDI number round-tripped through midiNumberToNote,
header parsing against all four real glyph tunes, ABC transcription against
those same tunes' notes arrays, every onset of a 240-note tune against an
independently computed millisecond, every tempoName boundary, nextGlyph
wraparound both ways, a full cosmology swap and restore, and X: renumbering.
One test walks the built output and fails if any DOM global or unexpected import
appears in it. Another registers a resolver hook that makes midi-writer-js
unreachable, imports the whole package anyway, and fails if evaluating it needed
the CommonJS dependency.
