Building Note Reader

A music-reading app for a choir and guitar teacher's students, built to run offline on school Chromebooks

Vanilla JS Bootstrap 5.3 VexFlow WebAudioFont Firebase Hosting Offline PWA

The brief

A choir and guitar teacher wanted something her students could use to learn to read music. Three things in one: a song library where you tap any note to hear it and see its name, drills for naming notes on the staff, and a builder for writing short melodies and handing them to a class as a link.

The constraints did most of the shaping. The students use school Chromebooks on flaky wifi, so it had to be an installable PWA that runs offline. No logins, no server, no database. Everything stays on the device. And no build step, so the whole thing is vanilla JavaScript served as plain files, with Bootstrap for the layout and VexFlow drawing the notation.

Sound that works with the wifi off

Tapping a note has to play a real piano or guitar, and it has to play with the network disabled. Most browser soundfont libraries fetch their samples from a CDN the first time you play a note, which is fine until you turn the wifi off in a classroom and nothing happens.

The original plan named smplr for playback. It has the cleanest API, but it pulls per-note sample files from a CDN at runtime, and caching that for offline is awkward. I switched to WebAudioFont instead. Each instrument is one self-contained JavaScript file with every sample inlined as base64, so I vendor two files under /samples (an acoustic grand and a nylon guitar) and the service worker caches them on first use. After that the sound works with the network off.

Audio is gated behind a tap

Chrome won't start audio until the user interacts with the page, so a note that plays on load fails silently. The AudioContext gets created and resumed on the first click, and the soundfont file loads lazily the first time you actually ask for that instrument.

The Workbox that phoned home

PWAs are hard to test because of caching. You change a file, reload, and the service worker hands back the old version. I added Workbox to handle updates properly, vendored its files locally to honor the no-CDN rule, and the service worker refused to register at all. The console said "ServiceWorker script evaluation failed" and nothing more.

The vendored workbox-sw.js is a tiny loader. Reach for workbox.precaching and it lazy-loads workbox-precaching.prod.js by calling importScripts on a hardcoded Google CDN URL. So the one file I vendored still tried to fetch its real modules from storage.googleapis.com at runtime, which fails with the network off and throws during evaluation. The library built to make a PWA work offline was the reason it didn't.

Keep the idea, drop the library

Workbox's one genuinely useful idea here is a precache manifest with a content hash per file. I kept that and dropped the rest. A small Node script hashes every app-shell file into sw-precache-manifest.js, and a hand-rolled service worker precaches by that manifest with plain Cache API calls. Each deploy where a file changed produces different bytes, the browser sees a new worker, and a toast offers a reload. No more guessing whether the cache is stale, and no runtime dependency on a CDN.

Click-to-place notation

The first builder asked you to pick a duration, then pick a pitch from a long list of note names. It worked and felt nothing like writing music. The teacher wanted to click notes onto the staff directly, so I rebuilt it around one rule: click a gap between notes (or the open end) to insert there, click an existing note to restamp it with the current duration and pitch, and switch on the eraser to remove. The click height sets the pitch, snapped to the nearest line or space.

On a desktop, a grey ghost note follows the cursor so you see what will land before you commit. Phones have no hover, so a tap places the note straight away and flashes it green for a moment to confirm. Both paths run through the same placement code; the only difference is whether a ghost previews it first.

Measures form on their own. Notes pack left to right, and a note that doesn't fit the beats left in a bar starts the next one. Bars are a visual guide here, not a rule to enforce, which is the right call for a beginner tool: the student is never blocked and never sees a warning about an over-full measure.

A piece a student builds is more useful if they can play it back, so the builder has an Edit/Play toggle. Play mode hides the palette and turns the staff read-only: tap a note to hear and name it, or play the whole thing through, with an option to float each note's letter above it as it sounds. That player is the same code the song library uses, pulled into one module so there's a single place to maintain it.

Sizing music glyphs

The palette buttons started out with Unicode music symbols like ♩ and 𝅗𝅥. Most system fonts don't carry those, so they showed up as tofu boxes or the wrong shape. I switched to drawing each glyph with VexFlow's Bravura font, the same engraving the staff uses, cropped to its content and dropped into the button as an SVG.

Then the whole note looked enormous next to the others. My first sizing pass scaled each glyph so its bounding box filled the button. A whole note is short and wide with no stem; a quarter note is tall and thin. Scaling both to the same box blew the whole note's head up to two or three times the size of the quarter's. The fix was to scale by notehead width instead of bounding box, so every head comes out the same size and the stems just run taller. A whole note reads slightly small at exact parity, so it gets about three extra pixels of width on purpose.

Flexbox squared the SVGs

Even with correct sizes set as width and height attributes, every glyph rendered as a 40-by-40 square and the wide whole note stretched to fill it. A flex container will size a replaced child like an SVG on its own and ignore the presentation attributes. Setting the dimensions as inline CSS styles, which win over both the attributes and the flex sizing, kept each glyph at its true aspect ratio.

Two more rest cases. A whole rest and a half rest are both small black rectangles, and side by side in a palette they're identical. On a real staff a whole rest hangs under a line and a half rest sits on one, so each gets a short reference line drawn flush against it to tell them apart. And a selected button turns navy, on which a black glyph disappears; the glyph paths inherited a black fill from the SVG root, so recoloring the root to currentColor lets the button's text color drive the glyph.

The guitar octave that broke the fretboard

The drills reveal where a note lives after you name it. In piano mode an 88-key keyboard lights up the right key; in guitar mode a horizontal fretboard shows every place you can play it. The fretboard looked wrong from the first test. Notes piled up at the top of the neck or fell off the end entirely.

The guitar is a transposing instrument. It's written in treble clef an octave above where it sounds, so middle C on the page is played at a spot that rings an octave lower. I was mapping the written pitch straight to fret positions at concert pitch, which pushed everything twelve frets too high. Subtracting an octave before finding the positions, and again before playing the note, put the markers back where a guitarist's fingers actually go.

Frets get closer together

The first fretboard spaced the frets evenly, which no real guitar does. The distance from the nut to fret n follows the rule the instrument is built on: scale × (1 − 2−n/12). The twelfth fret sits at the halfway point and the gaps shrink toward the bridge. With that in place the diagram reads like a guitar, and a tapped position plays its note an octave down to match.

The keyboard had its own orientation problem. Showing all 88 keys is too small on a phone, so it shows a window around the drilled notes, and a window of white and black keys with no labels gives you nothing to anchor on. Labeling every C cluttered it, so only middle C is marked, with a faint tint and a "Middle C" label under it. From there you can count.

A library of 45, and the bugs the audit caught

The library started with eight songs. The teacher wanted a real progression, so I expanded it to 45 public-domain melodies across three tiers: Easy (stepwise tunes in a small range, quarter and half notes), Intermediate (eighths, dotted rhythms, wider leaps), and Advanced (accidentals, minor keys, classical themes from Bach, Vivaldi, and Bizet). Fifteen per tier, single-line treble, the same JSON shape every other piece uses, with a level field for grouping.

Transcribing 45 melodies by hand is where errors hide, so I wrote a script that rendered every song offscreen and checked two things: that it drew without throwing, and that each measure's note durations summed to its time signature. The audit earned its keep on the first run.

Every accidental was silently wrong

I had written sharps and flats as fs/4 and bf/4. VexFlow wants f#/4 and bb/4, and it threw Invalid key name: FS on every song that used one. About a dozen of the intermediate and advanced songs failed to render until a one-line search-and-replace fixed the notation across the file.

The second check flagged 16 songs whose beats didn't divide into clean bars. Some were real pickup tunes, where a melody starts on an upbeat and my naive bar-splitter mis-divided everything after it. Others were my own miscounts, a measure with five beats in a four-four song. I reworked all 16 so every bar fills exactly, favoring a complete, readable measure over note-for-note fidelity to a particular recording. A few advanced themes came out simpler than their concert versions, which suits a tool a beginner reads from. The re-run came back clean: 45 songs, 15 per tier, zero render failures, zero short bars.

Showing where to play each note

Reading a note's name is one skill. Finding it on your instrument is another, and a student learning guitar or piano has to connect the two. So the player grew a "Show position" toggle. Turn it on and a diagram appears under the staff: a piano keyboard, or a guitar fretboard, depending on which instrument is selected. As a song plays, the diagram lights up where each note is played. Tap a single note and it shows that one spot.

The drills already had an 88-key piano and a horizontal fretboard, so the player reuses both modules instead of growing its own. Guitar shows the first-position fingering only, frets zero to four or an open string, which is what a beginner is taught to read first. The octave transposition I had fought through earlier paid off here: tap C on the staff and the fretboard marks the A string at the third fret, the spot that sounds a written C on a guitar.

Off by default, separate from note names

The staff stays clean unless a student asks for help. "Show position" and the existing "Show names while playing" are independent switches, both off to start, so you turn on the one aid you want. Building it into the shared player meant it showed up in the song library and the composition builder's Play mode from the same code.