npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@miadi/ava8-abcjs

v0.3.1

Published

The abcjs binding for Ava8: one score renderer with a pluggable loader (module/global/cdn/inject), multi-tune tunebook rendering, standalone SVG export, synth playback with a playback cursor, master volume and a leak-free lifecycle, ABC->MIDI bytes, and i

Downloads

622

Readme

@miadi/ava8-abcjs

The abcjs binding of Ava8 — one score renderer, one playback lifecycle, and a loader that lets abcjs arrive from wherever your runtime can reach it.

No React, no Next.js, no UI kit. It runs in a browser, and with the optional jsdom it runs headless in node — which is what ava8 render is built on.

Everything musical that does not need abcjs lives one layer down in @miadi/ava8-core: the ABC text model, notes↔MIDI, the instrument registry, tempo naming and the swappable glyph cosmology. This package uses core rather than restating it.

Install

pnpm add @miadi/ava8-abcjs

ESM only. There is no CommonJS build and no require() entry point: the package is "type": "module" with a single import condition. In a CommonJS file, reach it with await import('@miadi/ava8-abcjs').

abcjs comes with it, types included — you never add abcjs to your own dependencies to describe a visualObj, because this package re-exports TuneObject, AbcElem and NoteTimingEvent. jsdom does not come with it: it is an optional peer, wanted only by the node-side rendering paths. A browser consumer never installs it.

Why abcjs is pinned to 6.6.4

Three declarations in the prototype disagreed with each other:

| Where | What it said | | --- | --- | | package.json:41 | "abcjs": "latest" | | pnpm-lock.yaml:1388 | latest had resolved to 6.6.0 | | components/abc-notation-renderer.tsx:75 | a jsdelivr <script> pinned to 6.2.2 |

So one page could run two different abcjs builds at once — 6.6.0 through import("abcjs") in music-notation.tsx, and 6.2.2 through the <Script> tag in abc-notation-renderer.tsx. Two engravers, two synth implementations, one app.

Here there is one constant, abcjsPinnedVersion, and both the npm dependency and the default CDN URL are built from itstrategy:'cdn' fetches [email protected], never the stale 6.2.2. It is pinned exactly, not with a caret: the render tests assert on real SVG markup, and abcjs's engraving output is version-sensitive, so a floating range would turn a dependency bump into a mystery test failure. 6.6.4 is the newest patch of the 6.6 line the prototype's lockfile had already resolved to, so it is the version the prototype actually ran, with its patches.

import { abcjsPinnedVersion, defaultAbcjsCdnUrl } from '@miadi/ava8-abcjs'

abcjsPinnedVersion  // '6.6.4'
defaultAbcjsCdnUrl  // 'https://cdn.jsdelivr.net/npm/[email protected]/dist/abcjs-basic-min.js'

Two renderers became one

The prototype wrote the same feature twice, and the two copies disagreed:

| | abc-notation-renderer.tsx (A) | music-notation.tsx (B) | | --- | --- | --- | | how abcjs arrives | next/script from jsdelivr, then window.ABCJS | import("abcjs") inside the effect | | responsive | a prop, default 'resize' | hard-coded 'resize' | | scale | a prop, default 1 | hard-coded 0.8 | | staff width | 740 when not responsive | hard-coded 500 | | click listener | an empty one, "to enable note highlighting" | none | | errors | onError callback + an inline message | swallowed |

Neither was wrong; they were two answers to a question nobody had asked out loud. The reconciliation:

  • Loading became a parameter, not a fork in the code — AbcjsSource with four strategies. B's dynamic import is strategy:'module', the default. A's script tag is strategy:'cdn', now pointing at the pinned version.
  • A's defaults win every conflict but one. responsive and scale are options rather than constants, the staff width default is 740, and an empty clickListener is always supplied so abcjs installs its note-selection handling. B's 500 / 0.8 were one component's layout preference; they belong in a caller's props, not in a library.
  • Padding is the exception. Variant A forced all four sides to 0, and that choice reached every consumer of this package. Measured ink bounds at 740×439: left 0, right 0, bottom 0R:air shaved to air at the left edge, the composer line against the right edge, the last staff cut off. Ask for padding and you get it; ask for nothing and the four padding* keys are not sent at all, so abcjs's own margins apply.
  • Errors are returned, not swallowedrenderScore resolves with abcjs's warnings and core's validation, separately, and throws only when abcjs itself fails or when you asked for strict.

One function, renderScore, now covers both call sites.

The four loader strategies

type AbcjsSource =
  | { strategy: 'module' }            // import('abcjs')  — the default
  | { strategy: 'global' }            // window.ABCJS, already on the page
  | { strategy: 'cdn'; url?: string } // inject a <script>, default = the pinned jsdelivr build
  | { strategy: 'inject'; abcjs }     // you hand us the module

| Strategy | Use it when | | --- | --- | | 'module' | You have a bundler, or you are in node. abcjs is a real dependency and gets code-split like any other. Start here. | | 'global' | The host page already loads abcjs — a CMS, a Rails asset pipeline, another widget on the same page. Nothing is fetched; a missing global fails loudly instead of quietly loading a second copy. | | 'cdn' | No bundler: a plain <script type="module"> page, or a Next.js app that wants abcjs off the critical path. This is the prototype's variant A, with the version drift closed. | | 'inject' | Tests, SSR, and any runtime where you have already resolved abcjs yourself. Every test in this package uses it to drive the player without a browser. |

import { loadAbcjs, setDefaultAbcjsSource } from '@miadi/ava8-abcjs'

setDefaultAbcjsSource({ strategy: 'cdn' })   // process-wide default
const abcjs = await loadAbcjs()              // or pass a source per call

Concurrent calls share one in-flight promise: two components mounting in the same tick inject one <script>, not two. A load that rejects is evicted from the cache as it rejects, so one failed CDN fetch does not poison the process — a later call retries. Failures throw AbcjsLoadError, which carries the strategy that failed.

Rendering

import { renderScore, renderScoreToSvgString } from '@miadi/ava8-abcjs'
import { getGlyph } from '@miadi/ava8-core'

// In a browser — into an element you own. The container is cleared first.
const { svg, visualObj, warnings, validation, cleanup } =
  await renderScore(el, getGlyph('spiral').abcNotation)

// Headless — one standalone SVG document. This is what `ava8 render` runs.
const svgText = await renderScoreToSvgString(getGlyph('spiral').abcNotation)

Options

| Option | Default | What it does | | --- | --- | --- | | responsive | true for renderScore, false for the string forms | abcjs responsive:'resize' | | scale | 1 | Ignored while responsive is on — see below | | staffWidth | 740 when responsive is off | Honoured in both modes when you pass it | | addClasses | true | The abcjs-note / abcjs-staff classes highlighting needs | | padding | abcjs's own margins | 12, or { top, right, bottom, left } — sides you leave out fall back independently | | clickListener | an empty one | Receives one named AbcClickEvent, not five positional unknowns | | tuneIndex | 0 | Which tune of a multi-tune ABC to engrave -> abcjs startingTune | | format | — | abcjs's 25 typography attributes, so a score can match a design system | | selectionColor, dragColor | abcjs's hard-coded #ff0000 | The worst possible colour on a dark theme | | wrap | — | Real line reflow. With staffWidth, this is how a score fits a phone | | tablature | — | Guitar / mandolin tab | | visualTranspose | — | Engrave in another key without touching the ABC | | ariaLabel | abcjs's Sheet Music for "<title>" | | | print | — | abcjs print: true, page layout rather than screen layout | | strict | false | Reject instead of painting when validateAbc() reports errors | | abcjsParams | — | The escape hatch, applied last and overriding everything above |

abcjsParams is there so this package is never the reason you cannot reach abcjs. Anything not named above — jazzchords, oneSvgPerLine, lineBreaks, germanAlphabet, showDebug — goes through verbatim:

await renderScore(el, abc, { abcjsParams: { jazzchords: true, oneSvgPerLine: true } })

scale and responsive do not combine

abcjs drops scale whenever responsive is on — engraver-controller.js reads if (responsive === "resize") scale = undefined. A responsive score is sized by its container, which is what makes it responsive. Since responsive defaults to true for renderScore, scale: 2 on the default path has always been inert; now it says so on console.warn rather than quietly doing nothing. Pass responsive: false for scale to mean anything.

Warnings and validation are two different things

const { warnings, validation } = await renderScore(el, 'X:1\nT:Keyless\nM:4/4\nCDEF|')

warnings    // [] — abcjs engraves it happily and says nothing
validation  // { valid: false, errors: ['Missing K: key header', …], warnings: [] }

warnings is abcjs's parse output alone. validation is core's validateAbc verdict, unflattened — an error stays an error and a warning stays a warning. renderScore(el, '') and a keyless tune both used to come back with an svg, status: 'ready' and nothing at all to distinguish them from a clean render. Pass strict: true and a tune with errors rejects instead of painting.

The container is yours, and abcjs will write on it

abcjs mutates the element you hand it: display:inline-block; position:relative; width:100%; padding-bottom:<aspect>%; overflow:hidden, plus abcjs-container appended to its class. Clearing innerHTML undoes none of that, so a div that once held a score keeps a phantom aspect ratio forever.

const result = await renderScore(el, abc)
result.cleanup()               // clears it and puts back the original class/style
releaseScoreContainer(el)      // the same, when you did not keep the result

@miadi/ava8-react should still pass an inner div it owns exclusively: restoring attributes cannot restore a layout a host stylesheet already computed against the phantom padding.

Multi-tune ABC

renderAbc paints one tune per target element, so a two-tune tunebook handed to one element came back with one svg and warnings === [] — tune two gone with zero signal, while core's symphonyToAbc emits multi-tune ABC by design.

import { renderTunebook, renderTunebookToSvgStrings } from '@miadi/ava8-abcjs'

const { tunes } = await renderTunebook(el, abc)   // one owned child <div> per tune
tunes[1].svg                                      // the second movement, engraved

const svgs = await renderTunebookToSvgStrings(abc)  // one standalone document each

renderScore still engraves one tune — tuneIndex picks which — but it now reports tuneCount and pushes a warning naming what it left out. Silent truncation is gone either way.

The string forms are standalone documents

renderScoreToSvgString used to return svg.outerHTML, which carries xmlns:xlink and no xmlns="http://www.w3.org/2000/svg" — rejected by <img src>, by Inkscape and by every XML parser. It now serialises through XMLSerializer, which writes the namespace.

It also inherited responsive: true, so the string carried position:absolute with no width or height: measured in a browser, the host div collapsed to 4px and the score jumped to the corner of the viewport. A live element has a container to be responsive to; a string does not, so responsive defaults to false here. standalone (default true) guarantees dimensions and no absolute positioning even when you ask for responsive: true anyway; pass standalone: false for abcjs's raw fragment.

Document resolution for the string forms, in order: the document you pass, the global one, then a JSDOM built on demand. Only the last needs the optional dependency; without it you get a MissingDomError that says so.

abcjs reads the bare document global rather than an element's ownerDocument, so on the node path this package publishes the element's own view onto globalThis for the duration of the call and restores the previous descriptors in a finally. Importing this package in bare node touches no DOM global at all.

A score at one moment — renderScoreFrameToSvgString

The cursor below runs in a browser, driven by sound. renderScoreFrameToSvgString asks the same question with no audio, no browser and no TimingCallbacks: what does this score look like at 8400 ms?

import { renderScoreFrameToSvgString, scoreTimings } from '@miadi/ava8-abcjs'

const timeline = await scoreTimings(abc)
// [{ index: 0, startMs: 0, durationMs: 333 }, { index: 1, startMs: 333, ... }]

const frame = await renderScoreFrameToSvgString(abc, { atMs: 8400 })
const first = await renderScoreFrameToSvgString(abc, { noteIndex: 0 })

The note sounding at that moment carries cursorClassName (default 'ava8-cursor' — the same class useAbcCursor writes in @miadi/ava8-react, so one stylesheet rule dresses a live cursor and a rendered frame alike). No CSS ships with it:

.ava8-cursor { fill: #e26d5c; stroke: #e26d5c; }

Everything the string forms guarantee still holds, because it is the same code producing it: xmlns, real width / height, responsive off, standalone on, and every engraving option above. A frame opens in <img src> exactly as a plain export does.

| | | | --- | --- | | atMs or noteIndex | two ways to name one moment. Passing both throws — a caller who believes they are different is about to get the next frame wrong too. | | out of range | not an error. Before the first note or past the last, you get the score with nothing lit: a film's lead-in and its tail are legitimate frames of the same tune. | | neither | the plain export, byte for byte. No moment named, no note lit. | | tempo | qpm, clamped by core's clampTempo, so a frame and createPlayer({ tempo }) cannot disagree. Omitted means the tune's own Q:. |

scoreTimings engraves to obtain the numbers — abcjs computes them against the elements it drew — but nothing engraved survives the call. Rests are counted: abcjs emits them as timing events with real elements, a browser cursor moves onto them, and noteIndex addresses the same sequence a browser cursor walks.

Playback

import { createPlayer, stopAllAudio, unlockAudio } from '@miadi/ava8-abcjs'

await unlockAudio()   // iOS wants a user gesture before any sound

const player = await createPlayer(abc, {
  instrumentId: 'triangle',   // -> GM program 73, resolved through @miadi/ava8-core
  tempo: 96,                  // qpm; omitted -> the tune's own Q: header
  soundFontUrl: 'https://paulrosen.github.io/midi-js-soundfonts/FluidR3_GM/',  // default
  volume: 0.71,               // 0..1, live
  onEnded: () => setPlaying(false),
  onError: (err) => toast.error(err.message),
})

await player.play()
player.durationSeconds   // seconds, re-timed whenever setTempo is called
player.currentMs         // milliseconds into the tune
player.isPlaying
player.setTempo(132)     // clamped to Ava8's 40..240; applies on the next play()
player.setVolume(0)      // the mute toggle
player.pause()
player.resume()
player.seek(0.5, 'percent')   // or 'seconds' (default) or 'beats'
player.stop()
player.destroy()         // always call this on unmount

The lifecycle abcjs demands is preserved exactly: new synth.CreateSynth()init({ visualObj, options })prime()start().

onError is the error channel when you supply one — play() reports and resolves. Without it, play() rejects. The error is never lost and never delivered twice.

pause, resume and seek are a mapping onto abcjs's MidiBuffer, which already has all three; the end timer is re-armed on resume() for the time that is actually left, so a pause never shortens a tune.

The playback cursor

Hand the player the tune you already engraved and the cursor addresses the visible svg:

const { visualObj } = await renderScore(el, abc)

const player = await createPlayer(abc, {
  visualObj,                                    // <- this is the whole trick
  onEvent: (e) => e?.elements?.[0]?.[0]?.classList.add('is-playing'),
  onBeat: (beat, totalBeats, totalTime) => setProgress(beat / totalBeats),
  beatSubdivisions: 2,
})

Without visualObj, createPlayer parses the ABC a second time into renderAbc('*') — a throwaway target. The audio is identical, but every elements[0][0] on a timing event belongs to that detached parse: real <g class="abcjs-note …"> nodes that are not on the page, so highlighting them paints nothing. There was no way for a consumer holding the right object to hand it over. Now there is.

A TimingCallbacks is built only when onEvent or onBeat is supplied — an unwatched requestAnimationFrame loop per silent player is not free — and it is started and stopped alongside synth.start() / stop().

Volume

abcjs 6.6 has no volume API. CreateSynth bakes soundFontVolumeMultiplier into the rendered buffer at prime(), where it can no longer move, and then connects every directSource[n] straight to context.destination. That edge is the only place a live level can live, so volume / setVolume splices a GainNode into exactly that edge — which is what the prototype's master gain (app/page.tsx:86-88) did, and what core's defaults.volume: 71 has been describing to nobody ever since.

Volume changes ramp with the prototype's setTargetAtTime(…, 0.01); the initial attach is immediate, because a ramp there would fade in the start of every tune. A runtime with no Web Audio graph cannot have a live level, and says so on console.warn once rather than no-oping in silence.

ABC → MIDI

import { abcToMidiBytes, abcToMidiBlob } from '@miadi/ava8-abcjs'

const bytes = await abcToMidiBytes(abc, { instrumentId: 'sawtooth', tempo: 90 })
bytes instanceof Uint8Array           // true — node and browser get the same value
await fs.writeFile('tune.mid', bytes) // node

const blob = await abcToMidiBlob(abc)  // additive browser convenience, type 'audio/midi'

abcToMidiBytes is the contract export and returns bytes so node and the browser agree. abcToMidiBlob is additive: it exists so a download button does not have to re-wrap the bytes itself.

Audio unlock

import { getAudioContext, unlockAudio, isIOSDevice } from '@miadi/ava8-abcjs'

getAudioContext() returns one lazily created shared context, or null where there is no window. unlockAudio() resolves true once the context is running. Concurrent callers share one registration.

Additive exports

Beyond the frozen absorption contract:

| Export | Why | | --- | --- | | abcToMidiBlob | the browser wrapper around abcToMidiBytes | | abcjsPinnedVersion, defaultAbcjsCdnUrl | the one version constant, readable by consumers | | abcjsRenderParams | the reconciled defaults as data, so @miadi/ava8-react and the ava8 CLI apply one set rather than three | | resetAbcjsCache, getDefaultAbcjsSource | loader control for tests and for runtime CDN swaps | | defaultSoundFontUrl | the prototype's hard-coded soundfont, now nameable | | renderTunebook, renderTunebookToSvgStrings | multi-tune ABC, which core emits and this layer used to drop | | renderScoreFrameToSvgString, scoreTimings | the score at a moment, and the timeline it is a moment of — noteTimings was declared here and never read | | releaseScoreContainer | undo abcjs's mutations of a container you own | | clampVolume | the same 0..1 clamp setVolume applies, so a slider can show the value it will get | | Ava8AbcjsError, AbcjsLoadError, MissingDomError | typed failures | | AbcClickEvent, AbcjsNoteTimingEvent, AbcjsProgressUnit, AbcjsTimingCallbacks | the abcjs shapes this package hands you, named | | TuneObject, AbcElem, NoteTimingEvent, AbcVisualParams | abcjs's own declarations, re-exported so you never install them yourself |

Absorbed from

Source prototype: jgwill/Ava8@3cf080a (branch main). The prototype is read only — nothing here writes back to it.

| Here | Absorbed from | Change on the way in | | --- | --- | --- | | src/loader.ts 'cdn' | components/abc-notation-renderer.tsx:24-33,74-79next/script + handleScriptLoad/handleScriptError | next/script replaced with a plain <script> injection; concurrent callers deduped; the URL now carries the pinned version instead of 6.2.2 | | src/loader.ts 'module' | components/music-notation.tsx:15import("abcjs").then(...) | the same import, cached and shared, with the CommonJS default interop the prototype never needed inside webpack | | src/loader.ts 'global', 'inject' | (new) | what makes the reconciliation testable and SSR-safe | | src/render.ts renderScore | components/abc-notation-renderer.tsx:36-70 and components/music-notation.tsx:12-34 | the two renderers merged; variant A's defaults win (see above); innerHTML = "" clearing preserved from both | | src/render.ts renderScoreToSvgString | (new) | the headless path the prototype had no way to reach | | src/render.ts warnings | components/abc-notation-renderer.tsx:64-69 | variant A surfaced errors through onError, variant B swallowed them; now both abcjs parse warnings and core validateAbc findings are returned | | src/player.ts lifecycle | components/echo-composer.tsx:187-248 togglePlay | lifted out of a React component and off useState; end timer tracked and cleared (see below); stop() during init/prime no longer starts a stale synth | | src/player.ts stop() | components/echo-composer.tsx:93-120 stopAllAudio | preserved deliberately; split from two try blocks into three, one per call | | src/player.ts defaultSoundFontUrl | components/echo-composer.tsx:215 | the same FluidR3_GM URL, now an overridable option | | src/player.ts program / tempo | components/echo-composer.tsx:216-217 | getInstrumentMidiProgram moved to @miadi/ava8-core in the previous layer; this package calls instrumentMidiProgram and clampTempo rather than restating them | | src/midi.ts | (new) | abcjs.synth.getMidiFile, normalised to Uint8Array; the prototype could only export MIDI from a note array, never from ABC | | src/audio.ts | lib/audio-context.ts:1-62 | getAudioContext and isIOSDevice unchanged; initializeAudio became unlockAudio with the listener leak closed (see below) | | src/types.ts | types/abcjs.d.ts:1-17 | the same members, with any replaced by types strict can check, and the midiBuffer shape kept because the teardown still guards for it |

Two bugs closed while porting

The stale end timer. echo-composer.tsx:229-235 scheduled

setTimeout(() => { setIsPlaying(false) }, durationInSeconds * 1000)

and never held the handle. Stop a tune three seconds in, start it again, and the first run's timer was still alive — it fired at its original ten-second mark and flipped isPlaying false in the middle of the second run. Here the handle is tracked and cleared in both stop() and destroy().

The port also found that the value the prototype timed from, visualObj.endsAt, does not exist in abcjs 6.6 — the timer would have been scheduled from undefined. Duration now comes from abcjs 6's real API, setTiming(bpm, 0) then getTotalTime(), refined by synth.duration after init(), with endsAt kept as a fallback for older builds reached over the CDN.

The leaked gesture listeners. lib/audio-context.ts:47-49 registered three { once: true } listeners and removed them from inside an async handler. Whichever gesture arrived first resolved the promise and removed only itself — the other two survived and fired on some later, unrelated tap. unlockAudio() now latches on the first gesture and removes all three synchronously, before awaiting resume().

Deliberately preserved

stop() calls synth.stop, synth.midiBuffer.stop and ABCJS.synth.stopAll, each inside its own try/catch. In abcjs 6.6 the last two do not even exist — CreateSynth has no midiBuffer and the synth namespace has no stopAll. That is not a reason to delete them. This is the residue of f75b0e1 fix: properly stop audio playback in Echo Composer, a bug that was real, and the guards are what let one abcjs build's missing method not stop the others from being called. A test asserts that a throwing synth.stop still leaves midiBuffer.stop and stopAll to run.

Build and test

npm run build   # ../../node_modules/.bin/tsc -p tsconfig.build.json, strict, zero errors
npm test        # build, then node --test test/*.test.mjs

83 tests, asserting engraved SVG markup, measured widths, ordered call logs, argument values, audio-graph topology, MIDI bytes and timer state:

  • renderScoreToSvgString on every glyph in the shipped cosmology, checking staff, clef, key signature, note heads, stems, bar lines and <path> geometry — not merely that a string came back
  • the exported string parsed back as XML, checking documentElement.namespaceURI
  • padding: 770 wide by default, 740 with padding: 0, 795 with padding: { left: 40 } — one side named leaves the other three alone
  • wrap turning one 1305px staff line into seven that fit in 430px
  • two tunes in, two svgs out, Tune One in the first and not in the second
  • the container's class and style restored byte for byte after cleanup()
  • the loader injecting exactly one <script> for two concurrent calls, and retrying after a rejection
  • the player lifecycle driven against an injected fake, asserting new CreateSynth → init → prime → start with the resolved program, qpm and soundFontUrl
  • volume: every buffer source disconnected from the speakers and reconnected to a gain node, in that order, with the gain reaching destination last
  • the cursor built against the tune the reader can see — driven through real abcjs, asserting elements[0][0].isConnected and svg.contains(node) for all seven notes
  • seven frames across one tune, each lighting a different engraved <g> — the data-index and the notehead's drawn x coordinate advancing left to right, never a class merely present somewhere in the markup
  • atMs: 2666 lit and atMs: 2667 dark, on the boundary abcjs's own end event puts there, and noteIndex: n byte-for-byte identical to the atMs inside it
  • the stale-timer regression: play, stop at 3s, replay, advance past the first run's 10s mark, and isPlaying is still true
  • pause for an hour, resume, and the tune still has exactly its remaining six seconds
  • unlockAudio removing all three listeners, and a later gesture resuming nothing
  • every instrument id reaching the MIDI bytes as a real 0xC0 program change

One test walks the built output and fails if a DOM global is dereferenced outside src/env.ts, or if jsdom or abcjs ever becomes a static import.