@real-music-packages/web-core
v0.17.0
Published
Shared music-theory + audio primitives for the music-suite web apps
Readme
@real-music-packages/web-core
Shared, framework-agnostic music-theory primitives for the music-suite web apps (stave-web-sightread / RealSightReader and realeartrainer-web). Pure TypeScript — no DOM, Tone.js, Svelte, or OSMD. Published publicly on npmjs.com.
Modules
Core (default import /)
- notes —
NOTE_NAMES,NOTE_NAMES_FLAT,noteNameToIndex(name),pitchClass(midi),midiToNoteName(midi, useFlats?) - frequency —
midiToFrequency(midi)(equal temperament, A4=440) - enharmonic —
KEYS_PREFER_FLATS,useFlatsForKeyName(key),useFlatsForKeyFifths(fifths)(bridges RET's key-name model and Stave's keyFifths model) - scales —
MAJOR_SCALE_INTERVALS,ALL_KEYS,getMidiNote(degree, key, octave?),getScaleDegree(midi, key),isInScale(midi, key),getStability(degree) - intervals —
INTERVALS,intervalBySemitones(n) - chords —
CHORD_TEMPLATES(quality → interval set, richer qualities first)
./streak
localStorage-backed practice streak tracker — SSR-safe (silently no-ops when
localStorage is absent). createStreak(key, storage?) returns a StreakStore
with get(), record(today?), and live(today?). Injected fake storage makes
unit tests straightforward. todayKey() returns today as YYYY-MM-DD in local
time. Storage shape: {current, longest, last} — identical to RSR's existing key
so migration is free.
./server
Cloudflare D1 signup handler shared by RSR and RET. handleSignup(request, db, appTag)
handles POST {email, source?} → inserts into the shared signups D1 table with
source = ${appTag}:${source}. Returns 405/400/500/200 with cache-control: no-store.
The D1Like interface lets you pass any compatible D1 binding or a test fake.
./promo
Browser-only promo utilities (require Tone.js + opensheetmusicdisplay as optional
peers; OSMD is only loaded via a dynamic import). Pure helpers (parseMidi,
midiDurationMs) work in Node and are covered by unit tests.
parseMidi(buf)— minimal SMF parser: extracts note events with sustain-pedal extension and total duration.renderNotation(xml, opts?)— renders a MusicXML string via OSMD to a detached canvas; returns per-staff measure boxes (RSR geometry), per-measure column union boxes (RMT geometry), system rows, and content bounds. Parameterisable viaRenderNotationOpts(paper, inkSumThreshold, hostWidth, bars).createPromoSampler(opts?)— creates a Tone.js Salamander sampler wired to aMediaStreamDestination.keepAliveoption feeds a silent ConstantSource so the recorder never drops silent intro scenes (default false; RMT passes true).
./scene
Render-components: the Score model (scoreFromMusicXML), the Layer contract +
SceneSpec runner, and the built-in layers (notation, scroll-cursor, keyboard,
falling-notes, promo cards, spectrum, branding, and the S5 extended catalog).
This barrel is browser-safe — it pulls in no Node-only dependencies, so
Vite/rolldown consumers need no aliases. scoreFromMusicXML runs unchanged in
the browser (native canvas handles OSMD's lyric layout).
./scene/headless (Node-only)
setupHeadlessDom() — installs jsdom globals + a fake 2D canvas context so OSMD
can load() a score outside a browser (CI, batch, audio-only paths). Import it
only in Node, and call it once before scoreFromMusicXML (or pass
opts.osmdFactory):
import { setupHeadlessDom } from '@real-music-packages/web-core/scene/headless'; // Node only
import { scoreFromMusicXML } from '@real-music-packages/web-core/scene';
await setupHeadlessDom();
const score = scoreFromMusicXML(xml);This subpath references jsdom and must never be imported from browser code. It
lives here (not in ./scene) precisely so the ./scene barrel stays bundler-safe.
⚠️ Octave-base gotcha (scales.getMidiNote / getScaleDegree)
These are ported verbatim from RealEarTrainer and use RET's non-standard octave
base: getMidiNote(1, 'C', 4) === 48, i.e. one octave below the General-MIDI
convention (where C4 = 60). They are internally consistent and RET-only. The
general-purpose helpers (midiToNoteName, midiToFrequency, pitchClass) use the
standard GM convention (C4 = 60). Don't mix getMidiNote's output with the
standard helpers without accounting for the one-octave offset.
Install (consumers)
Published publicly on npmjs.com — no auth/token needed anywhere:
npm install @real-music-packages/web-coreDev
npm install
npm test # vitest
npm run build # tsup → dist/ (ESM + .d.ts)Publishing — auto-publishes from GitHub (no local npm publish)
To release: bump version in package.json and push to main. That's it.
The .github/workflows/publish.yml Action triggers on push to main (when
src/**, package.json, or tsup.config.ts change), on v* tags, or manual
dispatch. It is version-gated: it compares package.json version to what's
on npm and only runs npm publish --access public when the local version is new
(otherwise it no-ops). Auth uses the NPM_TOKEN repo secret — no local npm
login/token is needed anywhere, and you do NOT run npm publish by hand.
Consumers (Stave, RET-web, Whozart, RMT) pin caret ranges like ^0.8.0, so after
publishing a new minor you bump each consumer's pin + npm install to pick it up.
Practical note: pushing this repo's
mainIS the publish action. Don't push a version bump tomainuntil the change is ready to go live to every consumer.
